Merge branch 'master' into new-events-implementation
Signed-off-by: Alex Eftimie <alex.eftimie@getyourguide.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Bumped create-app version.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': minor
|
||||
---
|
||||
|
||||
Added `TestCaches` that functions just like `TestDatabases`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Fix readme for new plugins created using cli
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-react': minor
|
||||
'@backstage/plugin-catalog-import': minor
|
||||
'@backstage/plugin-kubernetes': patch
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Migrate from identityApi to fetchApi in frontend plugins.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Add support for JWKS tokens in ExternalTokenHandler.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-tasks': patch
|
||||
---
|
||||
|
||||
Updated the `TaskScheduleDefinitionConfig` deprecated comment to point to `SchedulerServiceTaskScheduleDefinitionConfig`
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Added an optional `accessRestrictions` to external access service tokens and service principals in general, such that you can limit their access to certain plugins or permissions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
Fix issue with `esm` loaded dependencies being different from the `cjs` import for Vite dependencies
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': patch
|
||||
---
|
||||
|
||||
Made it possible to give access restrictions to `mockCredentials.service`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-notifications': patch
|
||||
---
|
||||
|
||||
Fixes performance issue with Notifications title counter.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-permission-node': patch
|
||||
---
|
||||
|
||||
Ensure that service token access restrictions, when present, are taken into account
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-cloudflare-access-provider': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-sentry': patch
|
||||
'@backstage/plugin-scaffolder-backend-module-gitea': patch
|
||||
'@backstage/plugin-notifications-node': patch
|
||||
---
|
||||
|
||||
Use `node-fetch` instead of native fetch, as per https://backstage.io/docs/architecture-decisions/adrs-adr013
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-gitlab': patch
|
||||
---
|
||||
|
||||
Fixed an issue in `GitlabDiscoveryEntityProvider` where the fallback branch was taking precedence over the GitLab default branch.
|
||||
@@ -190,33 +190,63 @@
|
||||
"calm-plums-wink",
|
||||
"cold-seas-end",
|
||||
"create-app-1716302437",
|
||||
"create-app-1716903719",
|
||||
"cyan-jobs-visit",
|
||||
"cyan-paws-beg",
|
||||
"cyan-snails-peel",
|
||||
"eighty-kings-dress",
|
||||
"eighty-yaks-switch",
|
||||
"empty-spoons-tell",
|
||||
"empty-tables-ring",
|
||||
"famous-monkeys-count",
|
||||
"forty-adults-roll",
|
||||
"four-adults-mix",
|
||||
"friendly-keys-fold",
|
||||
"gentle-baboons-peel",
|
||||
"gold-teachers-wink",
|
||||
"great-cougars-guess",
|
||||
"itchy-spoons-cry",
|
||||
"large-months-decide",
|
||||
"late-ants-impress",
|
||||
"late-students-live",
|
||||
"little-cooks-approve",
|
||||
"loud-pumpkins-bow",
|
||||
"lovely-hats-pay",
|
||||
"lucky-taxis-rule",
|
||||
"many-moles-sing",
|
||||
"mean-laws-lay",
|
||||
"neat-rivers-share",
|
||||
"new-numbers-hug",
|
||||
"nice-pants-shave",
|
||||
"nine-hairs-kick",
|
||||
"nine-ties-type",
|
||||
"old-trees-check",
|
||||
"olive-mangos-tickle",
|
||||
"perfect-bikes-invite",
|
||||
"polite-otters-talk",
|
||||
"rude-kings-press",
|
||||
"seven-geese-raise",
|
||||
"shaggy-jokes-promise",
|
||||
"six-llamas-give",
|
||||
"slimy-fans-raise",
|
||||
"smooth-gifts-nail",
|
||||
"soft-flies-live",
|
||||
"sour-colts-juggle",
|
||||
"spicy-brooms-hang",
|
||||
"spicy-camels-happen",
|
||||
"spotty-plants-switch",
|
||||
"strong-moose-work",
|
||||
"stupid-tigers-bake",
|
||||
"tall-lies-fetch",
|
||||
"tall-pumas-teach",
|
||||
"tender-seas-listen",
|
||||
"thirty-plums-shout",
|
||||
"tiny-pandas-return",
|
||||
"warm-bees-hope",
|
||||
"weak-gifts-occur",
|
||||
"wet-crabs-guess",
|
||||
"wild-doors-cheat",
|
||||
"wild-ears-walk",
|
||||
"wise-vans-sin",
|
||||
"wise-wasps-look",
|
||||
"young-camels-return"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search-backend': patch
|
||||
---
|
||||
|
||||
Move @backstage/repo-tools to devDependencies
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/backend-defaults': minor
|
||||
'@backstage/backend-common': minor
|
||||
---
|
||||
|
||||
Deprecated and moved over core services to `@backstage/backend-defaults`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
|
||||
---
|
||||
|
||||
Add new `gitlab:pipeline:trigger` action to trigger GitLab pipelines.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': patch
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Fixing bug in `formData` type as it should be `optional` as it's possibly undefined
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-ldap': minor
|
||||
---
|
||||
|
||||
Migrate LDAP catalog module to the new backend system.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/repo-tools': patch
|
||||
---
|
||||
|
||||
Add `--client-additional-properties` option to `openapi generate` command
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': minor
|
||||
'@backstage/plugin-kubernetes-common': minor
|
||||
'@backstage/plugin-kubernetes-react': minor
|
||||
---
|
||||
|
||||
Update kubernetes plugins to use autoscaling/v2
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Export `catalogTranslationRef` under `/alpha`
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': patch
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
updated the ContextMenu, ActionsPage, OngoingTask and TemplateCard frontend components to support the new scaffolder permissions:
|
||||
|
||||
- `scaffolder.task.create`
|
||||
- `scaffolder.task.cancel`
|
||||
- `scaffolder.task.read`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Added a regex test to check commit hash. If url is from git commit branch ignore the edit url.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-plugin-api': patch
|
||||
---
|
||||
|
||||
Renamed `BackendPluginConfig`, `BackendModuleConfig`, and `ExtensionPointConfig` respectively to `CreateBackendPluginOptions`, `CreateBackendModuleOptions`, and `CreateExtensionPointOptions` to standardize frontend and backend factories signatures.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
'@backstage/plugin-scaffolder-common': patch
|
||||
---
|
||||
|
||||
added the following new permissions to the scaffolder backend endpoints:
|
||||
|
||||
- `scaffolder.task.create`
|
||||
- `scaffolder.task.cancel`
|
||||
- `scaffolder.task.read`
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
# Fetch the commit that's merged into the base rather than the target ref
|
||||
# This will let us diff only the contents of the PR, without fetching more history
|
||||
@@ -39,20 +39,18 @@ jobs:
|
||||
|
||||
- name: breaking changes check
|
||||
run: |
|
||||
yarn backstage-repo-tools repo schema openapi check --since origin/${{ github.base_ref }} > comment.md
|
||||
yarn backstage-repo-tools repo schema openapi diff --since origin/${{ github.base_ref }} > comment.md
|
||||
|
||||
- name: Upload Rendered Comment as Artifact
|
||||
- name: clone artifacts to current directory
|
||||
run: |
|
||||
cat ${{ github.event_path }} > event.json
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4
|
||||
with:
|
||||
name: preview-spec
|
||||
path: comment.md
|
||||
retention-days: 2
|
||||
overwrite: true
|
||||
|
||||
- name: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: ${{ github.event_path }}
|
||||
path: |
|
||||
comment.md
|
||||
event.json
|
||||
retention-days: 2
|
||||
overwrite: true
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
# Fetch the commit that's merged into the base rather than the target ref
|
||||
# This will let us diff only the contents of the PR, without fetching more history
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
ref: '${{ github.event.pull_request.merge_commit_sha }}'
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: use node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: use node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
ports:
|
||||
- 3306/tcp
|
||||
redis:
|
||||
image: redis
|
||||
image: redis:7
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
- name: fetch master branch
|
||||
run: git fetch origin master
|
||||
|
||||
@@ -240,7 +240,7 @@ jobs:
|
||||
BACKSTAGE_TEST_DATABASE_POSTGRES16_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres16.ports[5432] }}
|
||||
BACKSTAGE_TEST_DATABASE_POSTGRES12_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres12.ports[5432] }}
|
||||
BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING: mysql://root:root@localhost:${{ job.services.mysql8.ports[3306] }}/ignored
|
||||
BACKSTAGE_TEST_CACHE_REDIS_CONNECTION_STRING: redis://localhost:${{ job.services.redis.ports[6379] }}
|
||||
BACKSTAGE_TEST_CACHE_REDIS7_CONNECTION_STRING: redis://localhost:${{ job.services.redis.ports[6379] }}
|
||||
|
||||
# We run the test cases before verifying the specs to prevent any failing tests from causing errors.
|
||||
- name: verify openapi specs against test cases
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: checkout
|
||||
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
path: backstage
|
||||
ref: ${{ github.event.client_payload.version && env.RELEASE_VERSION || github.ref }}
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: use node.js 18.x
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: use node.js 18.x
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
|
||||
@@ -55,6 +55,15 @@ jobs:
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 3306/tcp
|
||||
redis:
|
||||
image: redis:7
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379/tcp
|
||||
|
||||
env:
|
||||
CI: true
|
||||
@@ -65,7 +74,7 @@ jobs:
|
||||
INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: use node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
@@ -115,6 +124,7 @@ jobs:
|
||||
BACKSTAGE_TEST_DATABASE_POSTGRES16_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres16.ports[5432] }}
|
||||
BACKSTAGE_TEST_DATABASE_POSTGRES12_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres12.ports[5432] }}
|
||||
BACKSTAGE_TEST_DATABASE_MYSQL8_CONNECTION_STRING: mysql://root:root@localhost:${{ job.services.mysql8.ports[3306] }}/ignored
|
||||
BACKSTAGE_TEST_CACHE_REDIS7_CONNECTION_STRING: redis://localhost:${{ job.services.redis.ports[6379] }}
|
||||
|
||||
- name: Discord notification
|
||||
if: ${{ failure() }}
|
||||
@@ -148,7 +158,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: use node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
|
||||
@@ -34,12 +34,12 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 'Checkout code'
|
||||
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Run analysis'
|
||||
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
|
||||
uses: ossf/scorecard-action@dc50aa9510b46c811795eb24b2f1ba02a914e534 # v2.3.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
# Fetch changes to previous commit - required for 'only_changed' in Prettier action
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
run: npm install semver@7.3.5 fs-extra@10.0.0 @manypkg/get-packages@1.1.1
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
path: backstage
|
||||
# 'v' prefix is added here for the tag, we keep it out of the manifest logic
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
# Checkout backstage/versions into /backstage/versions, which is where store the output
|
||||
- name: Checkout versions
|
||||
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
repository: backstage/versions
|
||||
path: backstage/versions
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: use node.js 18.x
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
- name: Monitor and Synchronize Snyk Policies
|
||||
uses: snyk/actions/node@8349f9043a8b7f0f3ee8885bf28f0b388d2446e8 # master
|
||||
with:
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
fetch-depth: 20000
|
||||
fetch-tags: true
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: checkout
|
||||
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: setup-node
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
- name: Render Compose File
|
||||
run: |
|
||||
# update image after the build above
|
||||
@@ -97,18 +97,19 @@ jobs:
|
||||
kustomize edit set image backstage=${{ needs.build-backstage.outputs.tags }}
|
||||
kustomize build . > manifests.rendered.yml
|
||||
cat manifests.rendered.yml
|
||||
- name: Upload Rendered Manifests File as Artifact
|
||||
|
||||
- name: clone artifacts into current directory
|
||||
run: |
|
||||
cat ${{ github.event_path }} > event.json
|
||||
cp ./.github/uffizzi/k8s/manifests/manifests.rendered.yml manifests.rendered.yml
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4
|
||||
with:
|
||||
name: preview-spec
|
||||
path: ./.github/uffizzi/k8s/manifests/manifests.rendered.yml
|
||||
retention-days: 2
|
||||
overwrite: true
|
||||
- name: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: ${{ github.event_path }}
|
||||
path: |
|
||||
manifests.rendered.yml
|
||||
event.json
|
||||
retention-days: 2
|
||||
overwrite: true
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
with:
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
# Vale does not support file excludes, so we use the script to generate a list of files instead
|
||||
# The action also does not allow args or a local config file to be passed in, so the files array
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: use node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
- uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
|
||||
with:
|
||||
python-version: '3.9'
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
git config --global core.autocrlf false
|
||||
git config --global core.eol lf
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: Install Fossa
|
||||
run: "curl -H 'Cache-Control: no-cache' https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash"
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: use node.js 18.x
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
fetch-depth: 0 # Required to retrieve git history
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- name: use node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
|
||||
|
||||
@@ -47,7 +47,10 @@ If you want to use the Lighthouse CLI and run the checks based on the config you
|
||||
yarn dlx @lhci/cli@0.11.x autorun
|
||||
```
|
||||
|
||||
> Note: running this command will use the [Lighthouse config](https://github.com/backstage/backstage/blob/39ba2284d73885b7ca8290cb38e2b1e4d983c8d6/lighthouserc.js#L19-L34) so make sure to adjust it to your needs if needed.
|
||||
:::note Note
|
||||
Running this command will use the [Lighthouse config](https://github.com/backstage/backstage/blob/39ba2284d73885b7ca8290cb38e2b1e4d983c8d6/lighthouserc.js#L19-L34) so make sure to adjust it to your needs if needed.
|
||||
|
||||
:::
|
||||
|
||||
### Use Lighthouse Github Action on your own repo
|
||||
|
||||
|
||||
@@ -80,6 +80,51 @@ header:
|
||||
Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
|
||||
```
|
||||
|
||||
## JWKS Token Auth
|
||||
|
||||
This access method allows for external caller token authentication using configured
|
||||
JSON Web Key Sets (JWKS). This is useful for callers that are authenticating to our
|
||||
instance of Backstage with third-party tools, such as Auth0.
|
||||
|
||||
You can configure this access method by adding one or more entries of type `jwks`
|
||||
to the `backend.auth.externalAccess` app-config key:
|
||||
|
||||
```yaml title="in e.g. app-config.production.yaml"
|
||||
backend:
|
||||
auth:
|
||||
externalAccess:
|
||||
- type: jwks
|
||||
options:
|
||||
url: https://example.com/.well-known/jwks.json
|
||||
issuer: https://example.com
|
||||
algorithm: RS256
|
||||
audience: example, other-example
|
||||
subjectPrefix: custom-prefix
|
||||
- type: jwks
|
||||
options:
|
||||
url: https://another-example.com/.well-known/jwks.json
|
||||
issuer: https://example.com
|
||||
```
|
||||
|
||||
The URL should point at an unauthenticated endpoint that returns the JWKS.
|
||||
|
||||
`issuer` specifies the issuer(s) of the JWT that the authenticating app will accept.
|
||||
Passed JWTs must have an `iss` claim which matches one of the specified issuers.
|
||||
|
||||
`algorithm` specifies the algorithm(s) that are used to verify the JWT. The passed JWTs
|
||||
must have been signed using one of the listed algorithms.
|
||||
|
||||
`audience` specifies the intended audience(s) of the JWT. The passed JWTs must have an "aud"
|
||||
claim that matches one of the audiences specified, or have no audience specified.
|
||||
|
||||
For additional details regarding the JWKS configuration, please consult your authentication
|
||||
provider's documentation.
|
||||
|
||||
The subject returned from the token verification will become part of the
|
||||
credentials object that the request recipient plugins get. All subjects will have the prefix
|
||||
`external:`, but you can also provide a custom subjectPrefix which will get appended before the
|
||||
subject returned from your JWKS service (ex. `external:custom-prefix:sub`).
|
||||
|
||||
## Legacy Tokens
|
||||
|
||||
Plugins and backends that are _not_ on the new backend system use a legacy token
|
||||
|
||||
@@ -111,7 +111,7 @@ There are only two possible scopes for services, `'plugin'` and `'root'`.
|
||||
|
||||
## Root Scoped Services
|
||||
|
||||
If a service is defined as a root scoped service, the implementation created by the factory will be shared across all plugins and services. One other differentiating factory for root scoped services is that they are always initialized, regardless of whether any plugins depend on them or not. This makes them suitable for implementing backend-wide concerns that are not specific to any individual plugin.
|
||||
If a service is defined as a root scoped service, the implementation created by the factory will be shared across all plugins and services. One other differentiating factor for root scoped services is that they are always initialized, regardless of whether any plugins depend on them or not. This makes them suitable for implementing backend-wide concerns that are not specific to any individual plugin.
|
||||
|
||||
There is a limitation in the usage of root scoped services, which is that their implementation can only depend on other root scoped services. Plugin scoped services on the other hand can depend on both root and plugin scoped services. Because of this limitation, one of the main reasons to define a root scoped services is to make it possible for other root scoped services to depend on it.
|
||||
|
||||
@@ -157,7 +157,7 @@ export const fooServiceFactory = createServiceFactory({
|
||||
});
|
||||
```
|
||||
|
||||
Whatever value is returned by the `createRootContext` function will shared and passed as the second argument to each invocation of the `factory` function. That way you can create a shared context that is used in the creation of each plugin instance. Unlike the `factory` function, the `createRootContext` function will only receive root scoped services as its dependencies, but just like the `factory` function, it can also be `async`.
|
||||
Whatever value is returned by the `createRootContext` function will be shared and passed as the second argument to each invocation of the `factory` function. That way you can create a shared context that is used in the creation of each plugin instance. Unlike the `factory` function, the `createRootContext` function will only receive root scoped services as its dependencies, but just like the `factory` function, it can also be `async`.
|
||||
|
||||
## Default Service Factories
|
||||
|
||||
|
||||
@@ -1369,7 +1369,7 @@ The vast majority of the backend plugins that currently live in the Backstage Re
|
||||
| @backstage/plugin-catalog-backend-module-github-org | backend-plugin-module | true | | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-github-org/README.md) |
|
||||
| @backstage/plugin-catalog-backend-module-gitlab | backend-plugin-module | true | true | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-gitlab/README.md) |
|
||||
| @backstage/plugin-catalog-backend-module-incremental-ingestion | backend-plugin-module | true | true | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-incremental-ingestion/README.md) |
|
||||
| @backstage/plugin-catalog-backend-module-ldap | backend-plugin-module | | | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-ldap/README.md) |
|
||||
| @backstage/plugin-catalog-backend-module-ldap | backend-plugin-module | true | | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-ldap/README.md) |
|
||||
| @backstage/plugin-catalog-backend-module-msgraph | backend-plugin-module | true | true | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-msgraph/README.md) |
|
||||
| @backstage/plugin-catalog-backend-module-openapi | backend-plugin-module | true | | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-openapi/README.md) |
|
||||
| @backstage/plugin-catalog-backend-module-puppetdb | backend-plugin-module | true | true | [README](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-puppetdb/README.md) |
|
||||
|
||||
+62
-5
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: authorizing-parameters-steps-and-actions
|
||||
title: 'Authorizing parameters, steps and actions'
|
||||
description: How to authorize part of a template
|
||||
id: authorizing-scaffolder-template-details
|
||||
title: 'Authorizing scaffolder tasks, parameters, steps, and actions'
|
||||
description: How to authorize parts of a template and authorize scaffolder task access
|
||||
---
|
||||
|
||||
The scaffolder plugin integrates with the Backstage [permission framework](../../permissions/overview.md), which allows you to control access to certain parameters and steps in your templates based on the user executing the template.
|
||||
The scaffolder plugin integrates with the Backstage [permission framework](../../permissions/overview.md), which allows you to control access to certain parameters and steps in your templates based on the user executing the template. It also allows you to control access to scaffolder tasks.
|
||||
|
||||
### Authorizing parameters and steps
|
||||
|
||||
@@ -174,7 +174,64 @@ class ExamplePermissionPolicy implements PermissionPolicy {
|
||||
}
|
||||
```
|
||||
|
||||
Although the rules exported by the scaffolder are simple, combining them can help you achieve more complex cases.
|
||||
### Authorizing scaffolder tasks
|
||||
|
||||
The scaffolder plugin also exposes permissions that can restrict access to tasks, task logs, task creation, and task cancellation. This can be useful if you want to control who has access to these areas of the scaffolder.
|
||||
|
||||
```ts title="packages/src/backend/plugins/permissions.ts"
|
||||
/* highlight-add-start */
|
||||
import {
|
||||
taskCancelPermission,
|
||||
taskCreatePermission,
|
||||
taskReadPermission,
|
||||
} from '@backstage/plugin-scaffolder-common/alpha';
|
||||
/* highlight-add-end */
|
||||
|
||||
class ExamplePermissionPolicy implements PermissionPolicy {
|
||||
async handle(
|
||||
request: PolicyQuery,
|
||||
user?: BackstageIdentityResponse,
|
||||
): Promise<PolicyDecision> {
|
||||
/* highlight-add-start */
|
||||
if (isPermission(request.permission, taskCreatePermission)) {
|
||||
if (user?.identity.userEntityRef === 'user:default/spiderman') {
|
||||
return {
|
||||
result: AuthorizeResult.ALLOW,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (isPermission(request.permission, taskCancelPermission)) {
|
||||
if (user?.identity.userEntityRef === 'user:default/spiderman') {
|
||||
return {
|
||||
result: AuthorizeResult.ALLOW,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (isPermission(request.permission, taskReadPermission)) {
|
||||
if (user?.identity.userEntityRef === 'user:default/spiderman') {
|
||||
return {
|
||||
result: AuthorizeResult.ALLOW,
|
||||
};
|
||||
}
|
||||
}
|
||||
/* highlight-add-end */
|
||||
|
||||
return {
|
||||
result: AuthorizeResult.DENY,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the provided example permission policy, we only grant the `spiderman` user permissions to perform/access the following actions/resources:
|
||||
|
||||
- Read all scaffolder tasks and their associated events/logs.
|
||||
- Cancel any ongoing scaffolder tasks.
|
||||
- Trigger software templates, which effectively creates new scaffolder tasks.
|
||||
|
||||
Any other user would be denied access to these actions/resources.
|
||||
|
||||
Although the rules exported by the scaffolder are simple, combining them can help you achieve more complex use cases.
|
||||
|
||||
### Authorizing in the New Backend System
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
---
|
||||
id: org--old
|
||||
title: LDAP Organizational Data
|
||||
sidebar_label: Org Data
|
||||
# prettier-ignore
|
||||
description: Setting up ingestion of organizational data from LDAP
|
||||
---
|
||||
|
||||
The Backstage catalog can be set up to ingest organizational data - users and
|
||||
groups - directly from an LDAP compatible service. The result is a hierarchy of
|
||||
[`User`](../../features/software-catalog/descriptor-format.md#kind-user) and
|
||||
[`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind
|
||||
entities that mirror your org setup.
|
||||
|
||||
## Supported vendors
|
||||
|
||||
Backstage in general supports OpenLDAP compatible vendors, as well as Active Directory and FreeIPA. If you are using a vendor that does not seem to be supported, please [file an issue](https://github.com/backstage/backstage/issues/new?assignees=&labels=enhancement&template=feature_template.md).
|
||||
|
||||
## Installation
|
||||
|
||||
This guide will use the Entity Provider method. If you for some reason prefer
|
||||
the Processor method (not recommended), it is described separately below.
|
||||
|
||||
The provider is not installed by default, therefore you have to add a dependency
|
||||
to `@backstage/plugin-catalog-backend-module-ldap` to your backend package.
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-ldap
|
||||
```
|
||||
|
||||
:::note Note
|
||||
|
||||
When configuring to use a Provider instead of a Processor you do not
|
||||
need to add a _location_ pointing to your LDAP server
|
||||
|
||||
:::
|
||||
|
||||
Update the catalog plugin initialization in your backend to add the provider and
|
||||
schedule it:
|
||||
|
||||
```ts title="packages/backend/src/plugins/catalog.ts"
|
||||
/* highlight-add-next-line */
|
||||
import { LdapOrgEntityProvider } from '@backstage/plugin-catalog-backend-module-ldap';
|
||||
|
||||
export default async function createPlugin(
|
||||
env: PluginEnvironment,
|
||||
): Promise<Router> {
|
||||
const builder = await CatalogBuilder.create(env);
|
||||
|
||||
/* highlight-add-start */
|
||||
// The target parameter below needs to match the ldap.providers.target
|
||||
// value specified in your app-config.
|
||||
builder.addEntityProvider(
|
||||
LdapOrgEntityProvider.fromConfig(env.config, {
|
||||
id: 'our-ldap-master',
|
||||
target: 'ldaps://ds.example.net',
|
||||
logger: env.logger,
|
||||
schedule: env.scheduler.createScheduledTaskRunner({
|
||||
frequency: { minutes: 60 },
|
||||
timeout: { minutes: 15 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
/* highlight-add-end */
|
||||
|
||||
// ..
|
||||
}
|
||||
```
|
||||
|
||||
After this, you also have to add some configuration in your app-config that
|
||||
describes what you want to import for that target.
|
||||
|
||||
## Configuration
|
||||
|
||||
The following configuration is a small example of how a setup could look for
|
||||
importing groups and users from a corporate LDAP server.
|
||||
|
||||
```yaml
|
||||
ldap:
|
||||
providers:
|
||||
- target: ldaps://ds.example.net
|
||||
bind:
|
||||
dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net
|
||||
secret: ${LDAP_SECRET}
|
||||
users:
|
||||
dn: ou=people,ou=example,dc=example,dc=net
|
||||
options:
|
||||
filter: (uid=*)
|
||||
map:
|
||||
description: l
|
||||
set:
|
||||
metadata.customField: 'hello'
|
||||
groups:
|
||||
dn: ou=access,ou=groups,ou=example,dc=example,dc=net
|
||||
options:
|
||||
filter: (&(objectClass=some-group-class)(!(groupType=email)))
|
||||
map:
|
||||
description: l
|
||||
set:
|
||||
metadata.customField: 'hello'
|
||||
```
|
||||
|
||||
There may be many providers, each targeting a specific `target` which is
|
||||
supposed to match the `target` of a dedicated provider instance - i.e., you will
|
||||
add one entity provider class instance per target to ingest from.
|
||||
|
||||
These config blocks have a lot of options in them, so we will describe each
|
||||
"root" key within the block separately.
|
||||
|
||||
### target
|
||||
|
||||
This is the URL of the targeted server, typically on the form
|
||||
`ldaps://ds.example.net` for SSL enabled servers or `ldap://ds.example.net`
|
||||
without SSL.
|
||||
|
||||
#### target.tls.keys
|
||||
|
||||
`keys` in TLS options specifies location of a file, that contains private keys
|
||||
to establish connection with your LDAP server, in PEM format. See an example
|
||||
for Google Secure LDAP Service below.
|
||||
|
||||
#### target.tls.certs
|
||||
|
||||
`certs` in TLS options specifies location of a file, that contains certificate
|
||||
chains to establish connection with your LDAP server, in PEM format. See an
|
||||
example for Google Secure LDAP Service below.
|
||||
|
||||
### bind
|
||||
|
||||
The bind block specifies how the plugin should bind (essentially, to
|
||||
authenticate) towards the server. It has the following fields.
|
||||
|
||||
```yaml
|
||||
dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net
|
||||
secret: ${LDAP_SECRET}
|
||||
```
|
||||
|
||||
The `dn` is the full LDAP Distinguished Name for the user that the plugin
|
||||
authenticates itself as. At this point, only regular user based authentication
|
||||
is supported.
|
||||
|
||||
The `secret` is the password of the same user. In this example, it is given in
|
||||
the form of an environment variable `LDAP_SECRET`, that has to be set when the
|
||||
backend starts.
|
||||
|
||||
### users
|
||||
|
||||
The `users` block defines the settings that govern the reading and
|
||||
interpretation of users. Its fields are explained in separate sections below.
|
||||
|
||||
#### users.dn
|
||||
|
||||
The DN under which users are stored, e.g.
|
||||
`ou=people,ou=example,dc=example,dc=net`.
|
||||
|
||||
#### users.options
|
||||
|
||||
The search options to use when sending the query to the server, when reading all
|
||||
users. All the options are shown below, with their default values, but they are
|
||||
all optional.
|
||||
|
||||
```yaml
|
||||
options:
|
||||
# One of 'base', 'one', or 'sub'.
|
||||
scope: one
|
||||
# The filter is the one that you commonly will want to specify explicitly. It
|
||||
# is a string on the standard LDAP query format. Use it to select out the set
|
||||
# of users that are of actual interest to ingest. For example, you may want
|
||||
# to filter out disabled users.
|
||||
filter: (uid=*)
|
||||
# The attribute selectors for each item, as passed to the LDAP server.
|
||||
attributes: ['*', '+']
|
||||
# This field is either 'false' to disable paging when reading from the
|
||||
# server, or an object on the form '{ pageSize: 100, pagePause: true }' that
|
||||
# specifies the details of how the paging shall work.
|
||||
paged: false
|
||||
```
|
||||
|
||||
#### users.set
|
||||
|
||||
This optional piece lets you specify a number of JSON paths (on a.b.c form) and
|
||||
hard coded values to set on those paths. This can be useful for example if you
|
||||
want to hard code a namespace or similar on the generated entities.
|
||||
|
||||
```yaml
|
||||
set:
|
||||
# Just an example; the key and value can be anything
|
||||
metadata.namespace: 'ldap'
|
||||
```
|
||||
|
||||
#### users.map
|
||||
|
||||
Mappings from well known entity fields, to LDAP attribute names. This is where
|
||||
you are able to define how to interpret the attributes of each LDAP result item,
|
||||
and to move them into the corresponding entity fields. All the options are shown
|
||||
below, with their default values, but they are all optional.
|
||||
|
||||
If you leave out an optional mapping, it will still be copied using that default
|
||||
value. For example, even if you do not put in the field `displayName` in your
|
||||
config, the provider will still copy the attribute `cn` into the entity field
|
||||
`spec.profile.displayName`.
|
||||
|
||||
```yaml
|
||||
map:
|
||||
# The name of the attribute that holds the relative
|
||||
# distinguished name of each entry.
|
||||
rdn: uid
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the metadata.name field of the entity.
|
||||
name: uid
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the metadata.description field of the entity.
|
||||
description: description
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the spec.profile.displayName field of the entity.
|
||||
displayName: cn
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the spec.profile.email field of the entity.
|
||||
email: mail
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the spec.profile.picture field of the entity.
|
||||
picture: <nothing, left out>
|
||||
# The name of the attribute that shall be used for the values of
|
||||
# the spec.memberOf field of the entity.
|
||||
memberOf: memberOf
|
||||
```
|
||||
|
||||
### groups
|
||||
|
||||
The `groups` block defines the settings that govern the reading and
|
||||
interpretation of groups. Its fields are explained in separate sections below.
|
||||
|
||||
#### groups.dn
|
||||
|
||||
The DN under which groups are stored, e.g.
|
||||
`ou=people,ou=example,dc=example,dc=net`.
|
||||
|
||||
#### groups.options
|
||||
|
||||
The search options to use when sending the query to the server, when reading all
|
||||
groups. All the options are shown below, with their default values, but they are
|
||||
all optional.
|
||||
|
||||
```yaml
|
||||
options:
|
||||
# One of 'base', 'one', or 'sub'.
|
||||
scope: one
|
||||
# The filter is the one that you commonly will want to specify explicitly. It
|
||||
# is a string on the standard LDAP query format. Use it to select out the set
|
||||
# of groups that are of actual interest to ingest. For example, you may want
|
||||
# to filter out disabled groups.
|
||||
filter: (&(objectClass=some-group-class)(!(groupType=email)))
|
||||
# The attribute selectors for each item, as passed to the LDAP server.
|
||||
attributes: ['*', '+']
|
||||
# This field is either 'false' to disable paging when reading from the
|
||||
# server, or an object on the form '{ pageSize: 100, pagePause: true }' that
|
||||
# specifies the details of how the paging shall work.
|
||||
paged: false
|
||||
```
|
||||
|
||||
#### groups.set
|
||||
|
||||
This optional piece lets you specify a number of JSON paths (on a.b.c form) and
|
||||
hard coded values to set on those paths. This can be useful for example if you
|
||||
want to hard code a namespace or similar on the generated entities.
|
||||
|
||||
```yaml
|
||||
set:
|
||||
# Just an example; the key and value can be anything
|
||||
metadata.namespace: 'ldap'
|
||||
```
|
||||
|
||||
#### groups.map
|
||||
|
||||
Mappings from well known entity fields, to LDAP attribute names. This is where
|
||||
you are able to define how to interpret the attributes of each LDAP result item,
|
||||
and to move them into the corresponding entity fields. All of the options are
|
||||
shown below, with their default values, but they are all optional.
|
||||
|
||||
If you leave out an optional mapping, it will still be copied using that default
|
||||
value. For example, even if you do not put in the field `displayName` in your
|
||||
config, the provider will still copy the attribute `cn` into the entity field
|
||||
`spec.profile.displayName`. If the target field is optional, such as the display
|
||||
name, the importer will accept missing attributes and just leave the target
|
||||
field unset. If the target field is mandatory, such as the name of the entity,
|
||||
validation will fail if the source attribute is missing.
|
||||
|
||||
```yaml
|
||||
map:
|
||||
# The name of the attribute that holds the relative
|
||||
# distinguished name of each entry. This value is copied into a
|
||||
# well known annotation to be able to query by it later.
|
||||
rdn: cn
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the metadata.name field of the entity.
|
||||
name: cn
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the metadata.description field of the entity.
|
||||
description: description
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the spec.type field of the entity.
|
||||
type: groupType
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the spec.profile.displayName field of the entity.
|
||||
displayName: cn
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the spec.profile.email field of the entity.
|
||||
email: <nothing, left out>
|
||||
# The name of the attribute that shall be used for the value of
|
||||
# the spec.profile.picture field of the entity.
|
||||
picture: <nothing, left out>
|
||||
# The name of the attribute that shall be used for the values of
|
||||
# the spec.parent field of the entity.
|
||||
memberOf: memberOf
|
||||
# The name of the attribute that shall be used for the values of
|
||||
# the spec.children field of the entity.
|
||||
members: member
|
||||
```
|
||||
|
||||
## Customize the Provider
|
||||
|
||||
In case you want to customize the ingested entities, the provider allows to pass
|
||||
transformers for users and groups. Here we will show an example of overriding
|
||||
the group transformer.
|
||||
|
||||
1. Create a transformer:
|
||||
|
||||
```ts
|
||||
export async function myGroupTransformer(
|
||||
vendor: LdapVendor,
|
||||
config: GroupConfig,
|
||||
group: SearchEntry,
|
||||
): Promise<GroupEntity | undefined> {
|
||||
// Transformations may change namespace, change entity naming pattern, fill
|
||||
// profile with more or other details...
|
||||
|
||||
// Create the group entity on your own, or wrap the default transformer
|
||||
return await defaultGroupTransformer(vendor, config, group);
|
||||
}
|
||||
```
|
||||
|
||||
2. Configure the provider with the transformer:
|
||||
|
||||
```ts
|
||||
const ldapEntityProvider = LdapOrgEntityProvider.fromConfig(env.config, {
|
||||
id: 'our-ldap-master',
|
||||
target: 'ldaps://ds.example.net',
|
||||
logger: env.logger,
|
||||
groupTransformer: myGroupTransformer,
|
||||
});
|
||||
```
|
||||
|
||||
## Using a Processor instead of a Provider
|
||||
|
||||
An alternative to using the Provider for ingesting LDAP entries is to use a
|
||||
Processor. This is the old way that's based on registering locations with the
|
||||
proper type and target, triggering the processor to run.
|
||||
|
||||
The drawback of this method is that it will leave orphaned Group/User entities
|
||||
whenever they are deleted on your LDAP server, and you cannot control the
|
||||
frequency with which they are refreshed, separately from other processors.
|
||||
|
||||
### Processor Installation
|
||||
|
||||
The `LdapOrgReaderProcessor` is not registered by default, so you have to
|
||||
register it in the catalog plugin:
|
||||
|
||||
```typescript title="packages/backend/src/plugins/catalog.ts"
|
||||
builder.addProcessor(
|
||||
LdapOrgReaderProcessor.fromConfig(env.config, {
|
||||
logger: env.logger,
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
### Driving LDAP Org Processor Ingestion with Locations
|
||||
|
||||
Locations point out the specific org(s) you want to import. The `type` of these
|
||||
locations must be `ldap-org`, and the `target` must point to the exact URL
|
||||
(starting with `ldap://` or `ldaps://`) of the targeted LDAP server. You can
|
||||
have several such location entries if you want, but typically you will have just
|
||||
one.
|
||||
|
||||
```yaml
|
||||
catalog:
|
||||
locations:
|
||||
- type: ldap-org
|
||||
target: ldaps://ds.example.net
|
||||
rules:
|
||||
- allow: [User, Group]
|
||||
```
|
||||
|
||||
### Example configurations
|
||||
|
||||
#### Google Secure LDAP Service
|
||||
|
||||
To sync Google Workspace/Cloud Identity organization data to users and groups in backstage,
|
||||
you must [configure Secure LDAP Service](https://support.google.com/a/answer/9048516) first.
|
||||
|
||||
Once Secure LDAP Service is configured, you can enable TLS options in LDAP configuration,
|
||||
as mentioned below. `keys` and `certs` specify the location of files that are generated
|
||||
while configuring Secure LDAP Service above.
|
||||
|
||||
```yaml
|
||||
ldap:
|
||||
providers:
|
||||
- target: ldaps://ldap.google.com:636
|
||||
tls:
|
||||
rejectUnauthorized: false
|
||||
keys: '/var/secrets/tls/gldap.key'
|
||||
certs: '/var/secrets/tls/gldap.crt'
|
||||
users:
|
||||
# users configuration comes here
|
||||
groups:
|
||||
# groups configuration comes here
|
||||
```
|
||||
+69
-154
@@ -18,9 +18,6 @@ Backstage in general supports OpenLDAP compatible vendors, as well as Active Dir
|
||||
|
||||
## Installation
|
||||
|
||||
This guide will use the Entity Provider method. If you for some reason prefer
|
||||
the Processor method (not recommended), it is described separately below.
|
||||
|
||||
The provider is not installed by default, therefore you have to add a dependency
|
||||
to `@backstage/plugin-catalog-backend-module-ldap` to your backend package.
|
||||
|
||||
@@ -29,47 +26,30 @@ to `@backstage/plugin-catalog-backend-module-ldap` to your backend package.
|
||||
yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-ldap
|
||||
```
|
||||
|
||||
:::note Note
|
||||
Next add the basic configuration to `app-config.yaml`
|
||||
|
||||
When configuring to use a Provider instead of a Processor you do not
|
||||
need to add a _location_ pointing to your LDAP server
|
||||
|
||||
:::
|
||||
|
||||
Update the catalog plugin initialization in your backend to add the provider and
|
||||
schedule it:
|
||||
|
||||
```ts title="packages/backend/src/plugins/catalog.ts"
|
||||
/* highlight-add-next-line */
|
||||
import { LdapOrgEntityProvider } from '@backstage/plugin-catalog-backend-module-ldap';
|
||||
|
||||
export default async function createPlugin(
|
||||
env: PluginEnvironment,
|
||||
): Promise<Router> {
|
||||
const builder = await CatalogBuilder.create(env);
|
||||
|
||||
/* highlight-add-start */
|
||||
// The target parameter below needs to match the ldap.providers.target
|
||||
// value specified in your app-config.
|
||||
builder.addEntityProvider(
|
||||
LdapOrgEntityProvider.fromConfig(env.config, {
|
||||
id: 'our-ldap-master',
|
||||
target: 'ldaps://ds.example.net',
|
||||
logger: env.logger,
|
||||
schedule: env.scheduler.createScheduledTaskRunner({
|
||||
frequency: { minutes: 60 },
|
||||
timeout: { minutes: 15 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
/* highlight-add-end */
|
||||
|
||||
// ..
|
||||
}
|
||||
```yaml title="app-config.yaml"
|
||||
catalog:
|
||||
providers:
|
||||
ldapOrg:
|
||||
default:
|
||||
target: ldaps://ds.example.net
|
||||
bind:
|
||||
dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net
|
||||
secret: ${LDAP_SECRET}
|
||||
schedule:
|
||||
frequency: PT1H
|
||||
timeout: PT15M
|
||||
```
|
||||
|
||||
After this, you also have to add some configuration in your app-config that
|
||||
describes what you want to import for that target.
|
||||
Finally, updated your backend by adding the following line:
|
||||
|
||||
```ts title="packages/backend/src/index.ts"
|
||||
backend.add(import('@backstage/plugin-catalog-backend/alpha'));
|
||||
/* highlight-add-start */
|
||||
backend.add(import('@backstage/plugin-catalog-backend-module-ldap'));
|
||||
/* highlight-add-end */
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -77,34 +57,32 @@ The following configuration is a small example of how a setup could look for
|
||||
importing groups and users from a corporate LDAP server.
|
||||
|
||||
```yaml
|
||||
ldap:
|
||||
catalog:
|
||||
providers:
|
||||
- target: ldaps://ds.example.net
|
||||
bind:
|
||||
dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net
|
||||
secret: ${LDAP_SECRET}
|
||||
users:
|
||||
dn: ou=people,ou=example,dc=example,dc=net
|
||||
options:
|
||||
filter: (uid=*)
|
||||
map:
|
||||
description: l
|
||||
set:
|
||||
metadata.customField: 'hello'
|
||||
groups:
|
||||
dn: ou=access,ou=groups,ou=example,dc=example,dc=net
|
||||
options:
|
||||
filter: (&(objectClass=some-group-class)(!(groupType=email)))
|
||||
map:
|
||||
description: l
|
||||
set:
|
||||
metadata.customField: 'hello'
|
||||
ldapOrg:
|
||||
default:
|
||||
target: ldaps://ds.example.net
|
||||
bind:
|
||||
dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net
|
||||
secret: ${LDAP_SECRET}
|
||||
users:
|
||||
dn: ou=people,ou=example,dc=example,dc=net
|
||||
options:
|
||||
filter: (uid=*)
|
||||
map:
|
||||
description: l
|
||||
set:
|
||||
metadata.customField: 'hello'
|
||||
groups:
|
||||
dn: ou=access,ou=groups,ou=example,dc=example,dc=net
|
||||
options:
|
||||
filter: (&(objectClass=some-group-class)(!(groupType=email)))
|
||||
map:
|
||||
description: l
|
||||
set:
|
||||
metadata.customField: 'hello'
|
||||
```
|
||||
|
||||
There may be many providers, each targeting a specific `target` which is
|
||||
supposed to match the `target` of a dedicated provider instance - i.e., you will
|
||||
add one entity provider class instance per target to ingest from.
|
||||
|
||||
These config blocks have a lot of options in them, so we will describe each
|
||||
"root" key within the block separately.
|
||||
|
||||
@@ -321,97 +299,34 @@ map:
|
||||
## Customize the Provider
|
||||
|
||||
In case you want to customize the ingested entities, the provider allows to pass
|
||||
transformers for users and groups. Here we will show an example of overriding
|
||||
the group transformer.
|
||||
transformers for users and groups.
|
||||
|
||||
1. Create a transformer:
|
||||
Transformers can be configured by extending `ldapOrgEntityProviderTransformExtensionPoint`. Here is an example:
|
||||
|
||||
```ts
|
||||
export async function myGroupTransformer(
|
||||
vendor: LdapVendor,
|
||||
config: GroupConfig,
|
||||
group: SearchEntry,
|
||||
): Promise<GroupEntity | undefined> {
|
||||
// Transformations may change namespace, change entity naming pattern, fill
|
||||
// profile with more or other details...
|
||||
```ts title="packages/backend/src/index.ts"
|
||||
import { createBackendModule } from '@backstage/backend-plugin-api';
|
||||
import { ldapOrgEntityProviderTransformExtensionPoint } from '@backstage/plugin-catalog-backend-module-ldap';
|
||||
import { myUserTransformer, myGroupTransformer } from './transformers';
|
||||
|
||||
// Create the group entity on your own, or wrap the default transformer
|
||||
return await defaultGroupTransformer(vendor, config, group);
|
||||
}
|
||||
```
|
||||
|
||||
2. Configure the provider with the transformer:
|
||||
|
||||
```ts
|
||||
const ldapEntityProvider = LdapOrgEntityProvider.fromConfig(env.config, {
|
||||
id: 'our-ldap-master',
|
||||
target: 'ldaps://ds.example.net',
|
||||
logger: env.logger,
|
||||
groupTransformer: myGroupTransformer,
|
||||
});
|
||||
```
|
||||
|
||||
## Using a Processor instead of a Provider
|
||||
|
||||
An alternative to using the Provider for ingesting LDAP entries is to use a
|
||||
Processor. This is the old way that's based on registering locations with the
|
||||
proper type and target, triggering the processor to run.
|
||||
|
||||
The drawback of this method is that it will leave orphaned Group/User entities
|
||||
whenever they are deleted on your LDAP server, and you cannot control the
|
||||
frequency with which they are refreshed, separately from other processors.
|
||||
|
||||
### Processor Installation
|
||||
|
||||
The `LdapOrgReaderProcessor` is not registered by default, so you have to
|
||||
register it in the catalog plugin:
|
||||
|
||||
```typescript title="packages/backend/src/plugins/catalog.ts"
|
||||
builder.addProcessor(
|
||||
LdapOrgReaderProcessor.fromConfig(env.config, {
|
||||
logger: env.logger,
|
||||
backend.add(
|
||||
createBackendModule({
|
||||
pluginId: 'catalog',
|
||||
moduleId: 'ldap-extensions',
|
||||
register(env) {
|
||||
env.registerInit({
|
||||
deps: {
|
||||
/* highlight-add-start */
|
||||
ldapTransformers: ldapOrgEntityProviderTransformExtensionPoint,
|
||||
/* highlight-add-end */
|
||||
},
|
||||
async init({ ldapTransformers }) {
|
||||
/* highlight-add-start */
|
||||
ldapTransformers.setUserTransformer(myUserTransformer);
|
||||
ldapTransformers.setGroupTransformer(myGroupTransformer);
|
||||
/* highlight-add-end */
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
### Driving LDAP Org Processor Ingestion with Locations
|
||||
|
||||
Locations point out the specific org(s) you want to import. The `type` of these
|
||||
locations must be `ldap-org`, and the `target` must point to the exact URL
|
||||
(starting with `ldap://` or `ldaps://`) of the targeted LDAP server. You can
|
||||
have several such location entries if you want, but typically you will have just
|
||||
one.
|
||||
|
||||
```yaml
|
||||
catalog:
|
||||
locations:
|
||||
- type: ldap-org
|
||||
target: ldaps://ds.example.net
|
||||
rules:
|
||||
- allow: [User, Group]
|
||||
```
|
||||
|
||||
### Example configurations
|
||||
|
||||
#### Google Secure LDAP Service
|
||||
|
||||
To sync Google Workspace/Cloud Identity organization data to users and groups in backstage,
|
||||
you must [configure Secure LDAP Service](https://support.google.com/a/answer/9048516) first.
|
||||
|
||||
Once Secure LDAP Service is configured, you can enable TLS options in LDAP configuration,
|
||||
as mentioned below. `keys` and `certs` specify the location of files that are generated
|
||||
while configuring Secure LDAP Service above.
|
||||
|
||||
```yaml
|
||||
ldap:
|
||||
providers:
|
||||
- target: ldaps://ldap.google.com:636
|
||||
tls:
|
||||
rejectUnauthorized: false
|
||||
keys: '/var/secrets/tls/gldap.key'
|
||||
certs: '/var/secrets/tls/gldap.crt'
|
||||
users:
|
||||
# users configuration comes here
|
||||
groups:
|
||||
# groups configuration comes here
|
||||
```
|
||||
|
||||
@@ -44,7 +44,11 @@ cd plugins/carmen-backend
|
||||
yarn start
|
||||
```
|
||||
|
||||
> Note: this documentation assumes you are using the latest version of Backstage and the new backend system. If you are not, please upgrade and migrate your backend using the [Migration Guide](../backend-system/building-backends/08-migrating.md)
|
||||
:::note Note
|
||||
|
||||
This documentation assumes you are using the latest version of Backstage and the new backend system. If you are not, please upgrade and migrate your backend using the [Migration Guide](../backend-system/building-backends/08-migrating.md)
|
||||
|
||||
:::
|
||||
|
||||
This will think for a bit, and then say `Listening on :7007`. In a different
|
||||
terminal window, now run
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -125,7 +125,7 @@ export class MyAwesomeApiClient implements MyAwesomeApi {
|
||||
|
||||
private async fetch<T = any>(input: string, init?: RequestInit): Promise<T> {
|
||||
// As configured previously for the backend proxy
|
||||
const proxyUri = '${await this.discoveryApi.getBaseUrl('proxy')}/<your-proxy-uri>';
|
||||
const proxyUri = `${await this.discoveryApi.getBaseUrl('proxy')}/<your-proxy-uri>`;
|
||||
|
||||
const resp = await fetch(`${proxyUri}${input}`, init);
|
||||
if (!resp.ok) throw new Error(resp);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: GraphQL Catalog
|
||||
author: Frontside Software
|
||||
authorUrl: https://frontside.com/
|
||||
category: Discovery
|
||||
description: Adds the GraphQL Endpoint to Backstage Catalog as a plugin.
|
||||
documentation: https://github.com/thefrontside/playhouse/blob/main/plugins/graphql-backend/README.md
|
||||
iconUrl: https://raw.githubusercontent.com/thefrontside/frontside.com/production/legacy/src/img/frontside-logo.png
|
||||
npmPackageName: '@frontside/backstage-plugin-graphql-backend'
|
||||
tags:
|
||||
- graphql
|
||||
- catalog
|
||||
- graphql-catalog
|
||||
addedDate: '2024-05-01'
|
||||
@@ -171,6 +171,10 @@ const config: Config = {
|
||||
from: '/docs/getting-started/configuration',
|
||||
to: '/docs/getting-started/#next-steps',
|
||||
},
|
||||
{
|
||||
from: '/docs/features/software-templates/authorizing-parameters-steps-and-actions',
|
||||
to: '/docs/features/software-templates/authorizing-scaffolder-template-details',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
"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",
|
||||
"features/software-templates/authorizing-scaffolder-template-details",
|
||||
"features/software-templates/migrating-to-rjsf-v5",
|
||||
"features/software-templates/migrating-from-v1beta2-to-v1beta3"
|
||||
]
|
||||
|
||||
+3
-3
@@ -3237,9 +3237,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@types/webpack-env@npm:^1.18.0":
|
||||
version: 1.18.4
|
||||
resolution: "@types/webpack-env@npm:1.18.4"
|
||||
checksum: f195b3ae974ac3b631477b57737dad7b6c44ecca86770cf3c29f284e02961c9f2dfc619e3e253d8c23966864cb052b1e8437e9834ede32ac97972e6e2235bb51
|
||||
version: 1.18.5
|
||||
resolution: "@types/webpack-env@npm:1.18.5"
|
||||
checksum: 4ca8eb4c44e1e1807c3e245442fce7aaf2816a163056de9436bbac44cc47c8bc5b1c9a330dc05748d6616431b1fb5bd5379733fb1da0b78d03c59f4ec824c184
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "root",
|
||||
"version": "1.28.0-next.0",
|
||||
"version": "1.28.0-next.1",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# example-app-next
|
||||
|
||||
## 0.0.12-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/cli@0.26.7-next.1
|
||||
- @backstage/plugin-catalog-import@0.12.0-next.1
|
||||
- @backstage/plugin-kubernetes@0.11.11-next.1
|
||||
- @backstage/plugin-search@1.4.12-next.1
|
||||
- @backstage/plugin-notifications@0.2.2-next.1
|
||||
- @backstage/plugin-scaffolder-react@1.8.7-next.1
|
||||
- @backstage/plugin-scaffolder@1.20.2-next.1
|
||||
- @backstage/plugin-catalog@1.20.1-next.1
|
||||
- @backstage/plugin-kubernetes-cluster@0.0.12-next.1
|
||||
- @backstage/plugin-catalog-react@1.12.1-next.0
|
||||
|
||||
## 0.0.12-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-app-next",
|
||||
"version": "0.0.12-next.0",
|
||||
"version": "0.0.12-next.1",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# example-app
|
||||
|
||||
## 0.2.98-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/cli@0.26.7-next.1
|
||||
- @backstage/plugin-catalog-import@0.12.0-next.1
|
||||
- @backstage/plugin-kubernetes@0.11.11-next.1
|
||||
- @backstage/plugin-search@1.4.12-next.1
|
||||
- @backstage/plugin-notifications@0.2.2-next.1
|
||||
- @backstage/plugin-scaffolder-react@1.8.7-next.1
|
||||
- @backstage/plugin-scaffolder@1.20.2-next.1
|
||||
- @backstage/plugin-catalog@1.20.1-next.1
|
||||
- @backstage/plugin-kubernetes-cluster@0.0.12-next.1
|
||||
- @backstage/plugin-catalog-react@1.12.1-next.0
|
||||
|
||||
## 0.2.98-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-app",
|
||||
"version": "0.2.98-next.0",
|
||||
"version": "0.2.98-next.1",
|
||||
"backstage": {
|
||||
"role": "frontend"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# @backstage/backend-app-api
|
||||
|
||||
## 0.7.6-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 398b82a: Add support for JWKS tokens in ExternalTokenHandler.
|
||||
- 9e63318: Added an optional `accessRestrictions` to external access service tokens and service principals in general, such that you can limit their access to certain plugins or permissions.
|
||||
- Updated dependencies
|
||||
- @backstage/backend-tasks@0.5.24-next.1
|
||||
- @backstage/backend-plugin-api@0.6.19-next.1
|
||||
- @backstage/plugin-permission-node@0.7.30-next.1
|
||||
- @backstage/backend-common@0.23.0-next.1
|
||||
- @backstage/cli-node@0.2.6-next.0
|
||||
- @backstage/config-loader@1.8.0
|
||||
- @backstage/plugin-auth-node@0.4.14-next.1
|
||||
|
||||
## 0.7.6-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import type { AppConfig } from '@backstage/config';
|
||||
import { AuthService } from '@backstage/backend-plugin-api';
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { CacheClient } from '@backstage/backend-common';
|
||||
import { CacheService } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ConfigSchema } from '@backstage/config-loader';
|
||||
import { CorsOptions } from 'cors';
|
||||
@@ -66,7 +66,7 @@ export interface Backend {
|
||||
}
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export const cacheServiceFactory: () => ServiceFactory<CacheClient, 'plugin'>;
|
||||
export const cacheServiceFactory: () => ServiceFactory<CacheService, 'plugin'>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createConfigSecretEnumerator(options: {
|
||||
|
||||
Vendored
+121
@@ -88,6 +88,46 @@ export interface Config {
|
||||
*/
|
||||
subject: string;
|
||||
};
|
||||
/**
|
||||
* Restricts what types of access that are permitted for this access
|
||||
* method. If no access restrictions are given, it'll have unlimited
|
||||
* access. This access restriction applies for the framework level;
|
||||
* individual plugins may have their own access control mechanisms
|
||||
* on top of this.
|
||||
*/
|
||||
accessRestrictions?: Array<{
|
||||
/**
|
||||
* Permit access to make requests to this plugin.
|
||||
*
|
||||
* Can be further refined by setting additional fields below.
|
||||
*/
|
||||
plugin: string;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* with these named permissions in this plugin.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permission?: string | Array<string>;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* whose permissions have these attributes.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permissionAttribute?: {
|
||||
/**
|
||||
* One of more of 'create', 'read', 'update', or 'delete'.
|
||||
*/
|
||||
action?: string | Array<string>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
@@ -130,6 +170,87 @@ export interface Config {
|
||||
*/
|
||||
subject: string;
|
||||
};
|
||||
/**
|
||||
* Restricts what types of access that are permitted for this access
|
||||
* method. If no access restrictions are given, it'll have unlimited
|
||||
* access. This access restriction applies for the framework level;
|
||||
* individual plugins may have their own access control mechanisms
|
||||
* on top of this.
|
||||
*/
|
||||
accessRestrictions?: Array<{
|
||||
/**
|
||||
* Permit access to make requests to this plugin.
|
||||
*
|
||||
* Can be further refined by setting additional fields below.
|
||||
*/
|
||||
plugin: string;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* with these named permissions in this plugin.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permission?: string | Array<string>;
|
||||
/**
|
||||
* If given, this method is limited to only performing actions
|
||||
* whose permissions have these attributes.
|
||||
*
|
||||
* Note that this only applies where permissions checks are
|
||||
* enabled in the first place. Endpoints that are not protected by
|
||||
* the permissions system at all, are not affected by this
|
||||
* setting.
|
||||
*/
|
||||
permissionAttribute?: {
|
||||
/**
|
||||
* One of more of 'create', 'read', 'update', or 'delete'.
|
||||
*/
|
||||
action?: string | Array<string>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* This access method consists of a JWKS endpoint that can be used to
|
||||
* verify JWT tokens.
|
||||
*
|
||||
* Callers generate JWT tokens via 3rd party tooling
|
||||
* and pass them in the Authorization header:
|
||||
*
|
||||
* ```
|
||||
* Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
|
||||
* ```
|
||||
*/
|
||||
type: 'jwks';
|
||||
options: {
|
||||
/**
|
||||
* The full URL of the JWKS endpoint.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Sets the algorithm(s) that should be used to verify the JWT tokens.
|
||||
* The passed JWTs must have been signed using one of the listed algorithms.
|
||||
*/
|
||||
algorithm?: string | string[];
|
||||
/**
|
||||
* Sets the issuer(s) that should be used to verify the JWT tokens.
|
||||
* Passed JWTs must have an `iss` claim which matches one of the specified issuers.
|
||||
*/
|
||||
issuer?: string | string[];
|
||||
/**
|
||||
* Sets the audience(s) that should be used to verify the JWT tokens.
|
||||
* The passed JWTs must have an "aud" claim that matches one of the audiences specified,
|
||||
* or have no audience specified.
|
||||
*/
|
||||
audience?: string | string[];
|
||||
/**
|
||||
* Sets an optional subject prefix. Passes the subject to called plugins.
|
||||
* Useful for debugging and tracking purposes.
|
||||
*/
|
||||
subjectPrefix?: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-app-api",
|
||||
"version": "0.7.6-next.0",
|
||||
"version": "0.7.6-next.1",
|
||||
"description": "Core API used by Backstage backend apps",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
|
||||
@@ -82,7 +82,11 @@ export class DefaultAuthService implements AuthService {
|
||||
|
||||
const externalResult = await this.externalTokenHandler.verifyToken(token);
|
||||
if (externalResult) {
|
||||
return createCredentialsWithServicePrincipal(externalResult.subject);
|
||||
return createCredentialsWithServicePrincipal(
|
||||
externalResult.subject,
|
||||
undefined,
|
||||
externalResult.accessRestrictions,
|
||||
);
|
||||
}
|
||||
|
||||
throw new AuthenticationError('Illegal token');
|
||||
|
||||
+59
-1
@@ -42,7 +42,26 @@ const mockDeps = [
|
||||
data: {
|
||||
backend: {
|
||||
baseUrl: 'http://localhost',
|
||||
auth: { keys: [{ secret: 'abc' }] },
|
||||
auth: {
|
||||
keys: [{ secret: 'abc' }],
|
||||
externalAccess: [
|
||||
{
|
||||
type: 'static',
|
||||
options: {
|
||||
token: 'limited-static-token',
|
||||
subject: 'limited-static-subject',
|
||||
},
|
||||
accessRestrictions: [{ plugin: 'catalog', permission: 'do.it' }],
|
||||
},
|
||||
{
|
||||
type: 'static',
|
||||
options: {
|
||||
token: 'unlimited-static-token',
|
||||
subject: 'unlimited-static-subject',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -385,4 +404,43 @@ describe('authServiceFactory', () => {
|
||||
"Unable to call 'kubernetes' plugin on behalf of user, because the target plugin does not support on-behalf-of tokens or the plugin doesn't exist",
|
||||
);
|
||||
});
|
||||
|
||||
it('should eagerly reject access to external access tokens based on plugin id', async () => {
|
||||
const tester = ServiceFactoryTester.from(authServiceFactory, {
|
||||
dependencies: mockDeps,
|
||||
});
|
||||
|
||||
const catalogAuth = await tester.get('catalog');
|
||||
|
||||
await expect(
|
||||
catalogAuth.authenticate('limited-static-token'),
|
||||
).resolves.toMatchObject({
|
||||
principal: {
|
||||
subject: 'limited-static-subject',
|
||||
accessRestrictions: { permissionNames: ['do.it'] },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
catalogAuth.authenticate('unlimited-static-token'),
|
||||
).resolves.toMatchObject({
|
||||
principal: {
|
||||
subject: 'unlimited-static-subject',
|
||||
},
|
||||
});
|
||||
|
||||
const scaffolderAuth = await tester.get('scaffolder');
|
||||
|
||||
await expect(
|
||||
scaffolderAuth.authenticate('limited-static-token'),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"This token's access is restricted to plugin(s) 'catalog'"`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
scaffolderAuth.authenticate('unlimited-static-token'),
|
||||
).resolves.toMatchObject({
|
||||
principal: { subject: 'unlimited-static-subject' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,19 +39,7 @@ export const authServiceFactory = createServiceFactory({
|
||||
// new auth services in the new backend system.
|
||||
tokenManager: coreServices.tokenManager,
|
||||
},
|
||||
async createRootContext({ config, logger }) {
|
||||
const externalTokens = ExternalTokenHandler.create({
|
||||
config,
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
externalTokens,
|
||||
};
|
||||
},
|
||||
async factory(
|
||||
{ config, discovery, plugin, tokenManager, logger, database },
|
||||
{ externalTokens },
|
||||
) {
|
||||
async factory({ config, discovery, plugin, tokenManager, logger, database }) {
|
||||
const disableDefaultAuthPolicy = Boolean(
|
||||
config.getOptionalBoolean(
|
||||
'backend.auth.dangerouslyDisableDefaultAuthPolicy',
|
||||
@@ -73,6 +61,11 @@ export const authServiceFactory = createServiceFactory({
|
||||
publicKeyStore,
|
||||
discovery,
|
||||
});
|
||||
const externalTokens = ExternalTokenHandler.create({
|
||||
ownPluginId: plugin.getId(),
|
||||
config,
|
||||
logger,
|
||||
});
|
||||
|
||||
return new DefaultAuthService(
|
||||
userTokens,
|
||||
|
||||
Vendored
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* 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 { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api';
|
||||
import { ExternalTokenHandler } from './ExternalTokenHandler';
|
||||
import { TokenHandler } from './types';
|
||||
import {
|
||||
mockServices,
|
||||
setupRequestMockHandlers,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import { DateTime } from 'luxon';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
// Simplified copy of TokenFactory in @backstage/plugin-auth-backend
|
||||
interface AnyJWK extends Record<string, string> {
|
||||
use: 'sig';
|
||||
alg: string;
|
||||
kid: string;
|
||||
kty: string;
|
||||
}
|
||||
class FakeTokenFactory {
|
||||
private readonly keys = new Array<AnyJWK>();
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
issuer: string;
|
||||
keyDurationSeconds: number;
|
||||
},
|
||||
) {}
|
||||
|
||||
async issueToken(params: {
|
||||
claims: {
|
||||
sub: string;
|
||||
ent?: string[];
|
||||
};
|
||||
}): Promise<string> {
|
||||
const pair = await generateKeyPair('RS256');
|
||||
const publicKey = await exportJWK(pair.publicKey);
|
||||
const kid = uuid();
|
||||
publicKey.kid = kid;
|
||||
this.keys.push(publicKey as AnyJWK);
|
||||
|
||||
const iss = this.options.issuer;
|
||||
const sub = params.claims.sub;
|
||||
const ent = params.claims.ent;
|
||||
const aud = 'backstage';
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + this.options.keyDurationSeconds;
|
||||
|
||||
return new SignJWT({ iss, sub, aud, iat, exp, ent, kid })
|
||||
.setProtectedHeader({ alg: 'RS256', ent: ent, kid: kid })
|
||||
.setIssuer(iss)
|
||||
.setAudience(aud)
|
||||
.setSubject(sub)
|
||||
.setIssuedAt(iat)
|
||||
.setExpirationTime(exp)
|
||||
.sign(pair.privateKey);
|
||||
}
|
||||
|
||||
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
|
||||
return { keys: this.keys };
|
||||
}
|
||||
}
|
||||
|
||||
describe('ExternalTokenHandler', () => {
|
||||
const server = setupServer();
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
it('skips over inner handlers that do not match, and applies plugin restrictions', async () => {
|
||||
const handler1: TokenHandler = {
|
||||
add: jest.fn(),
|
||||
verifyToken: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const handler2: TokenHandler = {
|
||||
add: jest.fn(),
|
||||
verifyToken: jest.fn().mockResolvedValue({
|
||||
subject: 'sub',
|
||||
allAccessRestrictions: new Map(
|
||||
Object.entries({
|
||||
plugin1: {
|
||||
permissionNames: ['do.it'],
|
||||
} satisfies BackstagePrincipalAccessRestrictions,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
const plugin1 = new ExternalTokenHandler('plugin1', [handler1, handler2]);
|
||||
const plugin2 = new ExternalTokenHandler('plugin2', [handler1, handler2]);
|
||||
|
||||
await expect(plugin1.verifyToken('token')).resolves.toEqual({
|
||||
subject: 'sub',
|
||||
accessRestrictions: { permissionNames: ['do.it'] },
|
||||
});
|
||||
await expect(
|
||||
plugin2.verifyToken('token'),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"This token's access is restricted to plugin(s) 'plugin1'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('successfully parses known methods', async () => {
|
||||
const legacyKey = randomBytes(24);
|
||||
|
||||
const factory = new FakeTokenFactory({
|
||||
issuer: 'blah',
|
||||
keyDurationSeconds: 100,
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://example.com/.well-known/jwks.json',
|
||||
async (_, res, ctx) => {
|
||||
const keys = await factory.listPublicKeys();
|
||||
return res(ctx.json(keys));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const handler = ExternalTokenHandler.create({
|
||||
ownPluginId: 'catalog',
|
||||
logger: mockServices.logger.mock(),
|
||||
config: mockServices.rootConfig({
|
||||
data: {
|
||||
backend: {
|
||||
auth: {
|
||||
externalAccess: [
|
||||
{
|
||||
type: 'legacy',
|
||||
options: {
|
||||
secret: legacyKey.toString('base64'),
|
||||
subject: 'legacy-subject',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'static',
|
||||
options: {
|
||||
token: 'defdefdef',
|
||||
subject: 'static-subject',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'jwks',
|
||||
options: {
|
||||
url: 'https://example.com/.well-known/jwks.json',
|
||||
algorithm: 'RS256',
|
||||
issuer: 'blah',
|
||||
audience: 'backstage',
|
||||
subjectPrefix: 'custom-prefix',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const legacyToken = await new SignJWT({
|
||||
sub: 'backstage-server',
|
||||
exp: DateTime.now().plus({ minutes: 1 }).toUnixInteger(),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(legacyKey);
|
||||
|
||||
await expect(handler.verifyToken(legacyToken)).resolves.toEqual({
|
||||
subject: 'legacy-subject',
|
||||
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
|
||||
});
|
||||
|
||||
await expect(handler.verifyToken('defdefdef')).resolves.toEqual({
|
||||
subject: 'static-subject',
|
||||
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
|
||||
});
|
||||
|
||||
const jwksToken = await factory.issueToken({
|
||||
claims: { sub: 'jwks-subject' },
|
||||
});
|
||||
await expect(handler.verifyToken(jwksToken)).resolves.toEqual({
|
||||
subject: 'external:custom-prefix:jwks-subject',
|
||||
accessRestrictions: { permissionNames: ['catalog.entity.read'] },
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+45
-7
@@ -15,15 +15,19 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
BackstagePrincipalAccessRestrictions,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
import { LegacyTokenHandler } from './legacy';
|
||||
import { StaticTokenHandler } from './static';
|
||||
import { JWKSHandler } from './jwks';
|
||||
import { TokenHandler } from './types';
|
||||
|
||||
const NEW_CONFIG_KEY = 'backend.auth.externalAccess';
|
||||
const OLD_CONFIG_KEY = 'backend.auth.keys';
|
||||
let loggedDeprecationWarning = false;
|
||||
|
||||
/**
|
||||
* Handles all types of external caller token types (i.e. not Backstage user
|
||||
@@ -33,16 +37,19 @@ const OLD_CONFIG_KEY = 'backend.auth.keys';
|
||||
*/
|
||||
export class ExternalTokenHandler {
|
||||
static create(options: {
|
||||
ownPluginId: string;
|
||||
config: RootConfigService;
|
||||
logger: LoggerService;
|
||||
}): ExternalTokenHandler {
|
||||
const { config, logger } = options;
|
||||
const { ownPluginId, config, logger } = options;
|
||||
|
||||
const staticHandler = new StaticTokenHandler();
|
||||
const legacyHandler = new LegacyTokenHandler();
|
||||
const jwksHandler = new JWKSHandler();
|
||||
const handlers: Record<string, TokenHandler> = {
|
||||
static: staticHandler,
|
||||
legacy: legacyHandler,
|
||||
jwks: jwksHandler,
|
||||
};
|
||||
|
||||
// Load the new-style handlers
|
||||
@@ -58,12 +65,13 @@ export class ExternalTokenHandler {
|
||||
`Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`,
|
||||
);
|
||||
}
|
||||
handler.add(handlerConfig.getConfig('options'));
|
||||
handler.add(handlerConfig);
|
||||
}
|
||||
|
||||
// Load the old keys too
|
||||
const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? [];
|
||||
if (legacyConfigs.length) {
|
||||
if (legacyConfigs.length && !loggedDeprecationWarning) {
|
||||
loggedDeprecationWarning = true;
|
||||
logger.warn(
|
||||
`DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`,
|
||||
);
|
||||
@@ -72,18 +80,48 @@ export class ExternalTokenHandler {
|
||||
legacyHandler.addOld(handlerConfig);
|
||||
}
|
||||
|
||||
return new ExternalTokenHandler(Object.values(handlers));
|
||||
return new ExternalTokenHandler(ownPluginId, Object.values(handlers));
|
||||
}
|
||||
|
||||
constructor(private readonly handlers: TokenHandler[]) {}
|
||||
constructor(
|
||||
private readonly ownPluginId: string,
|
||||
private readonly handlers: TokenHandler[],
|
||||
) {}
|
||||
|
||||
async verifyToken(token: string): Promise<{ subject: string } | undefined> {
|
||||
async verifyToken(token: string): Promise<
|
||||
| {
|
||||
subject: string;
|
||||
accessRestrictions?: BackstagePrincipalAccessRestrictions;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
for (const handler of this.handlers) {
|
||||
const result = await handler.verifyToken(token);
|
||||
if (result) {
|
||||
return result;
|
||||
const { allAccessRestrictions, ...rest } = result;
|
||||
if (allAccessRestrictions) {
|
||||
const accessRestrictions = allAccessRestrictions.get(
|
||||
this.ownPluginId,
|
||||
);
|
||||
if (!accessRestrictions) {
|
||||
const valid = [...allAccessRestrictions.keys()]
|
||||
.map(k => `'${k}'`)
|
||||
.join(', ');
|
||||
throw new NotAllowedError(
|
||||
`This token's access is restricted to plugin(s) ${valid}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
accessRestrictions,
|
||||
};
|
||||
}
|
||||
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
readAccessRestrictionsFromConfig,
|
||||
readStringOrStringArrayFromConfig,
|
||||
} from './helpers';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
|
||||
describe('readStringOrStringArrayFromConfig', () => {
|
||||
it('handles all cases correctly', () => {
|
||||
const config = mockServices.rootConfig({
|
||||
data: {
|
||||
wrongType: 1,
|
||||
wrongTypeInArray: [1],
|
||||
singleString: 'a',
|
||||
spaceSeparatedString: 'a b c',
|
||||
commaSeparatedString: 'a,b,c',
|
||||
mixedSeparatorsString: 'a b,c ,, d',
|
||||
emptyString: '',
|
||||
emptyArray: [],
|
||||
simpleArray: ['a', 'b', 'c'],
|
||||
arrayWithSeparators: ['a b', 'c,d', 'e'],
|
||||
complexDuplicates: ['a', 'a b', 'a', 'b, a'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
readStringOrStringArrayFromConfig(config, 'wrongType'),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'wrongType' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
readStringOrStringArrayFromConfig(config, 'wrongTypeInArray'),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'wrongTypeInArray[0]' in 'mock-config', got number, wanted string-array"`,
|
||||
);
|
||||
expect(readStringOrStringArrayFromConfig(config, 'singleString')).toEqual([
|
||||
'a',
|
||||
]);
|
||||
expect(
|
||||
readStringOrStringArrayFromConfig(config, 'spaceSeparatedString'),
|
||||
).toEqual(['a', 'b', 'c']);
|
||||
expect(
|
||||
readStringOrStringArrayFromConfig(config, 'commaSeparatedString'),
|
||||
).toEqual(['a', 'b', 'c']);
|
||||
expect(
|
||||
readStringOrStringArrayFromConfig(config, 'mixedSeparatorsString'),
|
||||
).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(() =>
|
||||
readStringOrStringArrayFromConfig(config, 'emptyString'),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'emptyString' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(
|
||||
readStringOrStringArrayFromConfig(config, 'emptyArray'),
|
||||
).toBeUndefined();
|
||||
expect(readStringOrStringArrayFromConfig(config, 'simpleArray')).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
expect(
|
||||
readStringOrStringArrayFromConfig(config, 'arrayWithSeparators'),
|
||||
).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||
expect(
|
||||
readStringOrStringArrayFromConfig(config, 'complexDuplicates'),
|
||||
).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readAccessRestrictionsFromConfig', () => {
|
||||
function r(config: JsonObject) {
|
||||
return readAccessRestrictionsFromConfig(new ConfigReader(config));
|
||||
}
|
||||
|
||||
it('handles empty / missing restrictions', () => {
|
||||
expect(r({})).toBeUndefined();
|
||||
expect(r({ accessRestrictions: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles type errors', () => {
|
||||
expect(() =>
|
||||
r({ accessRestrictions: 7 }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions' in 'mock-config', got number, wanted object-array"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: ['hello'] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0]' in 'mock-config', got string, wanted object-array"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: [{ unknown: {} }] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid key 'unknown' in 'accessRestrictions' config, expected one of 'plugin', 'permission', 'permissionAttribute'"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: [{ plugin: 7 }] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].plugin' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: [{ plugin: 'valid', permission: 7 }] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].permission' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: [{ plugin: 'valid', permission: [7] }] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].permission[0]' in 'mock-config', got number, wanted string-array"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({ accessRestrictions: [{ plugin: 'valid', permissionAttribute: 7 }] }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].permissionAttribute' in 'mock-config', got number, wanted object"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{ plugin: 'valid', permissionAttribute: { a: [] } },
|
||||
],
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid key 'a' in 'permissionAttribute' config, expected 'action'"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{ plugin: 'valid', permissionAttribute: { action: 7 } },
|
||||
],
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].permissionAttribute.action' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{ plugin: 'valid', permissionAttribute: { action: 'wrong' } },
|
||||
],
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid value 'wrong' at 'action' in 'permissionAttributes' config, valid values are 'create', 'read', 'update', 'delete'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('parses valid access restrictions', () => {
|
||||
expect(
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{
|
||||
plugin: 'a',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
new Map(
|
||||
Object.entries({
|
||||
a: {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{
|
||||
plugin: 'a',
|
||||
permission: 'a, b a',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
new Map(
|
||||
Object.entries({
|
||||
a: { permissionNames: ['a', 'b'] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{
|
||||
plugin: 'a',
|
||||
permission: ['a', 'b', 'a'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
new Map(
|
||||
Object.entries({
|
||||
a: { permissionNames: ['a', 'b'] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{
|
||||
plugin: 'a',
|
||||
permissionAttribute: { action: 'read, update read' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
new Map(
|
||||
Object.entries({
|
||||
a: { permissionAttributes: { action: ['read', 'update'] } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
r({
|
||||
accessRestrictions: [
|
||||
{
|
||||
plugin: 'a',
|
||||
permissionAttribute: { action: ['read', 'update', 'read'] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual(
|
||||
new Map(
|
||||
Object.entries({
|
||||
a: { permissionAttributes: { action: ['read', 'update'] } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 { Config } from '@backstage/config';
|
||||
import { AccessRestriptionsMap } from './types';
|
||||
|
||||
/**
|
||||
* Parses and returns the `accessRestrictions` configuration from an
|
||||
* `externalAccess` entry, or undefined if there wasn't one.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function readAccessRestrictionsFromConfig(
|
||||
externalAccessEntryConfig: Config,
|
||||
): AccessRestriptionsMap | undefined {
|
||||
const configs =
|
||||
externalAccessEntryConfig.getOptionalConfigArray('accessRestrictions') ??
|
||||
[];
|
||||
|
||||
const result: AccessRestriptionsMap = new Map();
|
||||
for (const config of configs) {
|
||||
const validKeys = ['plugin', 'permission', 'permissionAttribute'];
|
||||
for (const key of config.keys()) {
|
||||
if (!validKeys.includes(key)) {
|
||||
const valid = validKeys.map(k => `'${k}'`).join(', ');
|
||||
throw new Error(
|
||||
`Invalid key '${key}' in 'accessRestrictions' config, expected one of ${valid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const pluginId = config.getString('plugin');
|
||||
const permissionNames = readPermissionNames(config);
|
||||
const permissionAttributes = readPermissionAttributes(config);
|
||||
|
||||
if (result.has(pluginId)) {
|
||||
throw new Error(
|
||||
`Attempted to declare 'accessRestrictions' twice for plugin '${pluginId}', which is not permitted`,
|
||||
);
|
||||
}
|
||||
|
||||
result.set(pluginId, {
|
||||
...(permissionNames ? { permissionNames } : {}),
|
||||
...(permissionAttributes ? { permissionAttributes } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return result.size ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a config value as a string or an array of strings, and deduplicates and
|
||||
* splits by comma/space into a string array. Can also validate against a known
|
||||
* set of values. Returns undefined if the key didn't exist or if the array
|
||||
* would have ended up being empty.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function readStringOrStringArrayFromConfig<T extends string>(
|
||||
root: Config,
|
||||
key: string,
|
||||
validValues?: readonly T[],
|
||||
): T[] | undefined {
|
||||
if (!root.has(key)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawValues = Array.isArray(root.get(key))
|
||||
? root.getStringArray(key)
|
||||
: [root.getString(key)];
|
||||
|
||||
const values = [
|
||||
...new Set(
|
||||
rawValues
|
||||
.map(v => v.split(/[ ,]/))
|
||||
.flat()
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
|
||||
if (!values.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (validValues?.length) {
|
||||
for (const value of values) {
|
||||
if (!validValues.includes(value as T)) {
|
||||
const valid = validValues.map(k => `'${k}'`).join(', ');
|
||||
throw new Error(
|
||||
`Invalid value '${value}' at '${key}' in 'permissionAttributes' config, valid values are ${valid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values as T[];
|
||||
}
|
||||
|
||||
function readPermissionNames(externalAccessEntryConfig: Config) {
|
||||
return readStringOrStringArrayFromConfig(
|
||||
externalAccessEntryConfig,
|
||||
'permission',
|
||||
);
|
||||
}
|
||||
|
||||
function readPermissionAttributes(externalAccessEntryConfig: Config) {
|
||||
const config = externalAccessEntryConfig.getOptionalConfig(
|
||||
'permissionAttribute',
|
||||
);
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const validKeys = ['action'];
|
||||
for (const key of config.keys()) {
|
||||
if (!validKeys.includes(key)) {
|
||||
const valid = validKeys.map(k => `'${k}'`).join(', ');
|
||||
throw new Error(
|
||||
`Invalid key '${key}' in 'permissionAttribute' config, expected ${valid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const action = readStringOrStringArrayFromConfig(config, 'action', [
|
||||
'create',
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
]);
|
||||
|
||||
const result = {
|
||||
...(action ? { action } : {}),
|
||||
};
|
||||
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { JWKSHandler } from './jwks';
|
||||
|
||||
// Simplified copy of TokenFactory in @backstage/plugin-auth-backend
|
||||
interface AnyJWK extends Record<string, string> {
|
||||
use: 'sig';
|
||||
alg: string;
|
||||
kid: string;
|
||||
kty: string;
|
||||
}
|
||||
class FakeTokenFactory {
|
||||
private readonly keys = new Array<AnyJWK>();
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
issuer: string;
|
||||
keyDurationSeconds: number;
|
||||
},
|
||||
) {}
|
||||
|
||||
async issueToken(params: {
|
||||
claims: {
|
||||
sub: string;
|
||||
ent?: string[];
|
||||
};
|
||||
}): Promise<string> {
|
||||
const pair = await generateKeyPair('RS256');
|
||||
const publicKey = await exportJWK(pair.publicKey);
|
||||
const kid = uuid();
|
||||
publicKey.kid = kid;
|
||||
this.keys.push(publicKey as AnyJWK);
|
||||
|
||||
const iss = this.options.issuer;
|
||||
const sub = params.claims.sub;
|
||||
const ent = params.claims.ent;
|
||||
const aud = 'backstage';
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + this.options.keyDurationSeconds;
|
||||
|
||||
return new SignJWT({ iss, sub, aud, iat, exp, ent, kid })
|
||||
.setProtectedHeader({ alg: 'RS256', ent: ent, kid: kid })
|
||||
.setIssuer(iss)
|
||||
.setAudience(aud)
|
||||
.setSubject(sub)
|
||||
.setIssuedAt(iat)
|
||||
.setExpirationTime(exp)
|
||||
.sign(pair.privateKey);
|
||||
}
|
||||
|
||||
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
|
||||
return { keys: this.keys };
|
||||
}
|
||||
}
|
||||
|
||||
const server = setupServer();
|
||||
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
|
||||
|
||||
describe('JWKSHandler', () => {
|
||||
let factory: FakeTokenFactory;
|
||||
let mockSubject: string;
|
||||
const keyDurationSeconds = 5;
|
||||
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
beforeEach(() => {
|
||||
mockSubject = 'test_subject';
|
||||
|
||||
factory = new FakeTokenFactory({
|
||||
issuer: mockBaseUrl,
|
||||
keyDurationSeconds,
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get(`${mockBaseUrl}/.well-known/jwks.json`, async (_, res, ctx) => {
|
||||
const keys = await factory.listPublicKeys();
|
||||
return res(ctx.json(keys));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies token with valid entry', async () => {
|
||||
const validEntry = {
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: 'RS256',
|
||||
issuer: mockBaseUrl,
|
||||
audience: 'backstage',
|
||||
},
|
||||
};
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
jwksHandler.add(new ConfigReader(validEntry));
|
||||
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: mockSubject },
|
||||
});
|
||||
|
||||
const result = await jwksHandler.verifyToken(token);
|
||||
|
||||
expect(result).toEqual({ subject: `external:${mockSubject}` });
|
||||
});
|
||||
|
||||
it('skips invalid entry and continues verification', async () => {
|
||||
const invalidEntry = {
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: 'RS256',
|
||||
issuer: ['fakeIssuer'],
|
||||
audience: ['fakeAud'],
|
||||
},
|
||||
};
|
||||
|
||||
const validEntry = {
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: 'RS256',
|
||||
issuer: ['multiple-issuers', mockBaseUrl],
|
||||
audience: ['multiple-audiences', 'backstage'],
|
||||
},
|
||||
};
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
jwksHandler.add(new ConfigReader(invalidEntry));
|
||||
jwksHandler.add(new ConfigReader(validEntry));
|
||||
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: mockSubject },
|
||||
});
|
||||
|
||||
const result = await jwksHandler.verifyToken(token);
|
||||
|
||||
expect(result).toEqual({ subject: `external:${mockSubject}` });
|
||||
});
|
||||
|
||||
it('returns undefined if no valid entry found', async () => {
|
||||
const invalidEntry1 = {
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: 'RS256',
|
||||
issuer: 'wrong',
|
||||
},
|
||||
};
|
||||
|
||||
const invalidEntry2 = {
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: ['HS256'],
|
||||
audience: 'wrong',
|
||||
},
|
||||
};
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
jwksHandler.add(new ConfigReader(invalidEntry1));
|
||||
jwksHandler.add(new ConfigReader(invalidEntry2));
|
||||
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: mockSubject },
|
||||
});
|
||||
|
||||
const result = await jwksHandler.verifyToken(token);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects bad config', () => {
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
expect(() => {
|
||||
jwksHandler.add(
|
||||
new ConfigReader({
|
||||
options: {
|
||||
url: 'https://exampl e.com/jwks',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}).toThrow('Illegal JWKS URL, must be a set of non-space characters');
|
||||
expect(() => {
|
||||
jwksHandler.add(
|
||||
new ConfigReader({
|
||||
options: {
|
||||
url: 'https://example.com/jwks\n',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}).toThrow('Illegal JWKS URL, must be a set of non-space characters');
|
||||
});
|
||||
|
||||
it('gracefully handles no added tokens', async () => {
|
||||
const handler = new JWKSHandler();
|
||||
await expect(handler.verifyToken('ghi')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses custom subject prefix if provided', async () => {
|
||||
const validEntry = {
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
algorithm: 'RS256',
|
||||
issuer: mockBaseUrl,
|
||||
audience: 'backstage',
|
||||
subjectPrefix: 'custom-prefix',
|
||||
},
|
||||
};
|
||||
const jwksHandler = new JWKSHandler();
|
||||
|
||||
jwksHandler.add(new ConfigReader(validEntry));
|
||||
|
||||
const token = await factory.issueToken({
|
||||
claims: { sub: mockSubject },
|
||||
});
|
||||
|
||||
const result = await jwksHandler.verifyToken(token);
|
||||
|
||||
expect(result).toEqual({
|
||||
subject: `external:${validEntry.options.subjectPrefix}:${mockSubject}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries over access restrictions', async () => {
|
||||
const jwksHandler = new JWKSHandler();
|
||||
jwksHandler.add(
|
||||
new ConfigReader({
|
||||
options: {
|
||||
url: `${mockBaseUrl}/.well-known/jwks.json`,
|
||||
},
|
||||
accessRestrictions: [{ plugin: 'scaffolder', permission: 'do.it' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const token = await factory.issueToken({ claims: { sub: mockSubject } });
|
||||
|
||||
await expect(jwksHandler.verifyToken(token)).resolves.toEqual({
|
||||
subject: `external:${mockSubject}`,
|
||||
allAccessRestrictions: new Map(
|
||||
Object.entries({
|
||||
scaffolder: { permissionNames: ['do.it'] },
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 { jwtVerify, createRemoteJWKSet, JWTVerifyGetKey } from 'jose';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
readAccessRestrictionsFromConfig,
|
||||
readStringOrStringArrayFromConfig,
|
||||
} from './helpers';
|
||||
import { AccessRestriptionsMap, TokenHandler } from './types';
|
||||
|
||||
/**
|
||||
* Handles `type: jwks` access.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class JWKSHandler implements TokenHandler {
|
||||
#entries: Array<{
|
||||
algorithms?: string[];
|
||||
audiences?: string[];
|
||||
issuers?: string[];
|
||||
subjectPrefix?: string;
|
||||
url: URL;
|
||||
jwks: JWTVerifyGetKey;
|
||||
allAccessRestrictions?: AccessRestriptionsMap;
|
||||
}> = [];
|
||||
|
||||
add(config: Config) {
|
||||
if (!config.getString('options.url').match(/^\S+$/)) {
|
||||
throw new Error(
|
||||
'Illegal JWKS URL, must be a set of non-space characters',
|
||||
);
|
||||
}
|
||||
|
||||
const algorithms = readStringOrStringArrayFromConfig(
|
||||
config,
|
||||
'options.algorithm',
|
||||
);
|
||||
const issuers = readStringOrStringArrayFromConfig(config, 'options.issuer');
|
||||
const audiences = readStringOrStringArrayFromConfig(
|
||||
config,
|
||||
'options.audience',
|
||||
);
|
||||
const subjectPrefix = config.getOptionalString('options.subjectPrefix');
|
||||
const url = new URL(config.getString('options.url'));
|
||||
const jwks = createRemoteJWKSet(url);
|
||||
const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
|
||||
|
||||
this.#entries.push({
|
||||
algorithms,
|
||||
audiences,
|
||||
issuers,
|
||||
jwks,
|
||||
subjectPrefix,
|
||||
url,
|
||||
allAccessRestrictions,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
for (const entry of this.#entries) {
|
||||
try {
|
||||
const {
|
||||
payload: { sub },
|
||||
} = await jwtVerify(token, entry.jwks, {
|
||||
algorithms: entry.algorithms,
|
||||
issuer: entry.issuers,
|
||||
audience: entry.audiences,
|
||||
});
|
||||
|
||||
if (sub) {
|
||||
const prefix = entry.subjectPrefix
|
||||
? `external:${entry.subjectPrefix}:`
|
||||
: 'external:';
|
||||
return {
|
||||
subject: `${prefix}${sub}`,
|
||||
allAccessRestrictions: entry.allAccessRestrictions,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+117
-33
@@ -25,17 +25,35 @@ describe('LegacyTokenHandler', () => {
|
||||
const key1 = randomBytes(24);
|
||||
const key2 = randomBytes(24);
|
||||
const key3 = randomBytes(24);
|
||||
const accessRestrictions1 = new Map(
|
||||
Object.entries({
|
||||
scaffolder: {},
|
||||
}),
|
||||
);
|
||||
const accessRestrictions2 = new Map(
|
||||
Object.entries({
|
||||
catalog: { permissionNames: ['catalog.entity.read'] },
|
||||
}),
|
||||
);
|
||||
|
||||
tokenHandler.add(
|
||||
new ConfigReader({
|
||||
secret: key1.toString('base64'),
|
||||
subject: 'key1',
|
||||
options: {
|
||||
secret: key1.toString('base64'),
|
||||
subject: 'key1',
|
||||
},
|
||||
accessRestrictions: [{ plugin: 'scaffolder' }],
|
||||
}),
|
||||
);
|
||||
tokenHandler.add(
|
||||
new ConfigReader({
|
||||
secret: key2.toString('base64'),
|
||||
subject: 'key2',
|
||||
options: {
|
||||
secret: key2.toString('base64'),
|
||||
subject: 'key2',
|
||||
},
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
tokenHandler.addOld(
|
||||
@@ -54,6 +72,7 @@ describe('LegacyTokenHandler', () => {
|
||||
|
||||
await expect(tokenHandler.verifyToken(token1)).resolves.toEqual({
|
||||
subject: 'key1',
|
||||
allAccessRestrictions: accessRestrictions1,
|
||||
});
|
||||
|
||||
const token2 = await new SignJWT({
|
||||
@@ -65,6 +84,7 @@ describe('LegacyTokenHandler', () => {
|
||||
|
||||
await expect(tokenHandler.verifyToken(token2)).resolves.toEqual({
|
||||
subject: 'key2',
|
||||
allAccessRestrictions: accessRestrictions2,
|
||||
});
|
||||
|
||||
const token3 = await new SignJWT({
|
||||
@@ -147,39 +167,93 @@ describe('LegacyTokenHandler', () => {
|
||||
|
||||
// new style add, bad secrets
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ _missingsecret: true, subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
handler.add(
|
||||
new ConfigReader({ options: { _missingsecret: true, subject: 'ok' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'options.secret' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: '', subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
handler.add(new ConfigReader({ options: { secret: '', subject: 'ok' } })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.secret' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'has spaces', subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
handler.add(
|
||||
new ConfigReader({ options: { secret: 'has spaces', subject: 'ok' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'hasnewline\n', subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { secret: 'hasnewline\n', subject: 'ok' },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 3, subject: 'ok' })),
|
||||
).toThrow(/secret/);
|
||||
handler.add(new ConfigReader({ options: { secret: 3, subject: 'ok' } })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.secret' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
|
||||
// new style add, bad subjects
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', _missingsubject: true })),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', subject: '' })),
|
||||
).toThrow(/subject/);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', subject: 'has spaces' })),
|
||||
).toThrow(/subject/);
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { secret: 'b2s=', _missingsubject: true },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'options.subject' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ secret: 'b2s=', subject: 'hasnewline\n' }),
|
||||
new ConfigReader({ options: { secret: 'b2s=', subject: '' } }),
|
||||
),
|
||||
).toThrow(/subject/);
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.subject' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ secret: 'b2s=', subject: 3 })),
|
||||
).toThrow(/subject/);
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { secret: 'b2s=', subject: 'has spaces' },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal subject, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { secret: 'b2s=', subject: 'hasnewline\n' },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal subject, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ options: { secret: 'b2s=', subject: 3 } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.subject' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
|
||||
// new style add, bad access restrictions
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { secret: 'b2s=', subject: 'subject' },
|
||||
accessRestrictions: [{ plugin: ['a'] }],
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'accessRestrictions[0].plugin' in 'mock-config', got array, wanted string"`,
|
||||
);
|
||||
|
||||
// old style add
|
||||
expect(() =>
|
||||
@@ -187,18 +261,28 @@ describe('LegacyTokenHandler', () => {
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ _missingsecret: true })),
|
||||
).toThrow(/secret/);
|
||||
expect(() => handler.addOld(new ConfigReader({ secret: '' }))).toThrow(
|
||||
/secret/,
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'secret' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: '' })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'secret' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 'has spaces' })),
|
||||
).toThrow(/secret/);
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 'hasnewline\n' })),
|
||||
).toThrow(/secret/);
|
||||
expect(() => handler.addOld(new ConfigReader({ secret: 3 }))).toThrow(
|
||||
/secret/,
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal secret, must be a valid base64 string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.addOld(new ConfigReader({ secret: 3 })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'secret' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+39
-13
@@ -16,7 +16,8 @@
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { base64url, decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
|
||||
import { TokenHandler } from './types';
|
||||
import { readAccessRestrictionsFromConfig } from './helpers';
|
||||
import { AccessRestriptionsMap, TokenHandler } from './types';
|
||||
|
||||
/**
|
||||
* Handles `type: legacy` access.
|
||||
@@ -24,21 +25,38 @@ import { TokenHandler } from './types';
|
||||
* @internal
|
||||
*/
|
||||
export class LegacyTokenHandler implements TokenHandler {
|
||||
#entries: Array<{ key: Uint8Array; subject: string }> = [];
|
||||
#entries = new Array<{
|
||||
key: Uint8Array;
|
||||
result: {
|
||||
subject: string;
|
||||
allAccessRestrictions?: AccessRestriptionsMap;
|
||||
};
|
||||
}>();
|
||||
|
||||
add(options: Config) {
|
||||
this.#doAdd(options.getString('secret'), options.getString('subject'));
|
||||
add(config: Config) {
|
||||
const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
|
||||
this.#doAdd(
|
||||
config.getString('options.secret'),
|
||||
config.getString('options.subject'),
|
||||
allAccessRestrictions,
|
||||
);
|
||||
}
|
||||
|
||||
// used only for the old backend.auth.keys array
|
||||
addOld(options: Config) {
|
||||
addOld(config: Config) {
|
||||
// This choice of subject is for compatibility reasons
|
||||
this.#doAdd(options.getString('secret'), 'external:backstage-plugin');
|
||||
this.#doAdd(config.getString('secret'), 'external:backstage-plugin');
|
||||
}
|
||||
|
||||
#doAdd(secret: string, subject: string) {
|
||||
#doAdd(
|
||||
secret: string,
|
||||
subject: string,
|
||||
allAccessRestrictions?: AccessRestriptionsMap,
|
||||
) {
|
||||
if (!secret.match(/^\S+$/)) {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
} else if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
}
|
||||
|
||||
let key: Uint8Array;
|
||||
@@ -48,11 +66,19 @@ export class LegacyTokenHandler implements TokenHandler {
|
||||
throw new Error('Illegal secret, must be a valid base64 string');
|
||||
}
|
||||
|
||||
if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
if (this.#entries.some(e => e.key === key)) {
|
||||
throw new Error(
|
||||
'Legacy externalAccess token was declared more than once',
|
||||
);
|
||||
}
|
||||
|
||||
this.#entries.push({ key, subject });
|
||||
this.#entries.push({
|
||||
key,
|
||||
result: {
|
||||
subject,
|
||||
allAccessRestrictions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
@@ -76,10 +102,10 @@ export class LegacyTokenHandler implements TokenHandler {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const entry of this.#entries) {
|
||||
for (const { key, result } of this.#entries) {
|
||||
try {
|
||||
await jwtVerify(token, entry.key);
|
||||
return { subject: entry.subject };
|
||||
await jwtVerify(token, key);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e.code !== 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
|
||||
throw e;
|
||||
|
||||
+63
-23
@@ -20,14 +20,36 @@ import { StaticTokenHandler } from './static';
|
||||
describe('StaticTokenHandler', () => {
|
||||
it('accepts any of the added list of tokens', async () => {
|
||||
const handler = new StaticTokenHandler();
|
||||
handler.add(new ConfigReader({ token: 'abcabcabc', subject: 'one' }));
|
||||
handler.add(new ConfigReader({ token: 'defdefdef', subject: 'two' }));
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { token: 'abcabcabc', subject: 'one' },
|
||||
accessRestrictions: [{ plugin: 'scaffolder' }],
|
||||
}),
|
||||
);
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { token: 'defdefdef', subject: 'two' },
|
||||
accessRestrictions: [
|
||||
{ plugin: 'catalog', permission: 'catalog.entity.read' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const accessRestrictionsOne = new Map(Object.entries({ scaffolder: {} }));
|
||||
const accessRestrictionsTwo = new Map(
|
||||
Object.entries({
|
||||
catalog: {
|
||||
permissionNames: ['catalog.entity.read'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(handler.verifyToken('abcabcabc')).resolves.toEqual({
|
||||
subject: 'one',
|
||||
allAccessRestrictions: accessRestrictionsOne,
|
||||
});
|
||||
await expect(handler.verifyToken('defdefdef')).resolves.toEqual({
|
||||
subject: 'two',
|
||||
allAccessRestrictions: accessRestrictionsTwo,
|
||||
});
|
||||
await expect(handler.verifyToken('ghighighi')).resolves.toBeUndefined();
|
||||
});
|
||||
@@ -41,71 +63,89 @@ describe('StaticTokenHandler', () => {
|
||||
const handler = new StaticTokenHandler();
|
||||
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ _missingtoken: true, subject: 'ok' })),
|
||||
handler.add(
|
||||
new ConfigReader({ options: { _missingtoken: true, subject: 'ok' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'token' in 'mock-config'"`,
|
||||
`"Missing required config value at 'options.token' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: '', subject: 'ok' })),
|
||||
handler.add(new ConfigReader({ options: { token: '', subject: 'ok' } })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'token' in 'mock-config', got empty-string, wanted string"`,
|
||||
`"Invalid type in config for key 'options.token' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'has spaces', subject: 'ok' })),
|
||||
handler.add(
|
||||
new ConfigReader({ options: { token: 'has spaces', subject: 'ok' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal token, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
token: 'hasnewlinebutislongenough\n',
|
||||
subject: 'ok',
|
||||
options: {
|
||||
token: 'hasnewlinebutislongenough\n',
|
||||
subject: 'ok',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal token, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'short', subject: 'ok' })),
|
||||
handler.add(
|
||||
new ConfigReader({ options: { token: 'short', subject: 'ok' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal token, must be at least 8 characters length"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 3, subject: 'ok' })),
|
||||
handler.add(new ConfigReader({ options: { token: 3, subject: 'ok' } })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'token' in 'mock-config', got number, wanted string"`,
|
||||
`"Invalid type in config for key 'options.token' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ token: 'validtoken', _missingsubject: true }),
|
||||
new ConfigReader({
|
||||
options: { token: 'validtoken', _missingsubject: true },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Missing required config value at 'subject' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'validtoken', subject: '' })),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'subject' in 'mock-config', got empty-string, wanted string"`,
|
||||
`"Missing required config value at 'options.subject' in 'mock-config'"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ token: 'validtoken', subject: 'has spaces' }),
|
||||
new ConfigReader({ options: { token: 'validtoken', subject: '' } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'options.subject' in 'mock-config', got empty-string, wanted string"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({
|
||||
options: { token: 'validtoken', subject: 'has spaces' },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal subject, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(
|
||||
new ConfigReader({ token: 'validtoken', subject: 'hasnewline\n' }),
|
||||
new ConfigReader({
|
||||
options: { token: 'validtoken', subject: 'hasnewline\n' },
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Illegal subject, must be a set of non-space characters"`,
|
||||
);
|
||||
expect(() =>
|
||||
handler.add(new ConfigReader({ token: 'validtoken', subject: 3 })),
|
||||
handler.add(
|
||||
new ConfigReader({ options: { token: 'validtoken', subject: 3 } }),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid type in config for key 'subject' in 'mock-config', got number, wanted string"`,
|
||||
`"Invalid type in config for key 'options.subject' in 'mock-config', got number, wanted string"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+22
-17
@@ -15,7 +15,8 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { TokenHandler } from './types';
|
||||
import { readAccessRestrictionsFromConfig } from './helpers';
|
||||
import { AccessRestriptionsMap, TokenHandler } from './types';
|
||||
|
||||
const MIN_TOKEN_LENGTH = 8;
|
||||
|
||||
@@ -25,33 +26,37 @@ const MIN_TOKEN_LENGTH = 8;
|
||||
* @internal
|
||||
*/
|
||||
export class StaticTokenHandler implements TokenHandler {
|
||||
#entries: Array<{ token: string; subject: string }> = [];
|
||||
#entries = new Map<
|
||||
string,
|
||||
{
|
||||
subject: string;
|
||||
allAccessRestrictions?: AccessRestriptionsMap;
|
||||
}
|
||||
>();
|
||||
|
||||
add(config: Config) {
|
||||
const token = config.getString('options.token');
|
||||
const subject = config.getString('options.subject');
|
||||
const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
|
||||
|
||||
add(options: Config) {
|
||||
const token = options.getString('token');
|
||||
if (!token.match(/^\S+$/)) {
|
||||
throw new Error('Illegal token, must be a set of non-space characters');
|
||||
}
|
||||
if (token.length < MIN_TOKEN_LENGTH) {
|
||||
} else if (token.length < MIN_TOKEN_LENGTH) {
|
||||
throw new Error(
|
||||
`Illegal token, must be at least ${MIN_TOKEN_LENGTH} characters length`,
|
||||
);
|
||||
}
|
||||
|
||||
const subject = options.getString('subject');
|
||||
if (!subject.match(/^\S+$/)) {
|
||||
} else if (!subject.match(/^\S+$/)) {
|
||||
throw new Error('Illegal subject, must be a set of non-space characters');
|
||||
} else if (this.#entries.has(token)) {
|
||||
throw new Error(
|
||||
'Static externalAccess token was declared more than once',
|
||||
);
|
||||
}
|
||||
|
||||
this.#entries.push({ token, subject });
|
||||
this.#entries.set(token, { subject, allAccessRestrictions });
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
const entry = this.#entries.find(e => e.token === token);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { subject: entry.subject };
|
||||
return this.#entries.get(token);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -14,9 +14,21 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BackstagePrincipalAccessRestrictions } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
export type AccessRestriptionsMap = Map<
|
||||
string, // plugin ID
|
||||
BackstagePrincipalAccessRestrictions
|
||||
>;
|
||||
|
||||
export interface TokenHandler {
|
||||
add(options: Config): void;
|
||||
verifyToken(token: string): Promise<{ subject: string } | undefined>;
|
||||
verifyToken(token: string): Promise<
|
||||
| {
|
||||
subject: string;
|
||||
allAccessRestrictions?: AccessRestriptionsMap;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import {
|
||||
BackstageCredentials,
|
||||
BackstageNonePrincipal,
|
||||
BackstagePrincipalAccessRestrictions,
|
||||
BackstageServicePrincipal,
|
||||
BackstageUserPrincipal,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
@@ -25,6 +26,7 @@ import { InternalBackstageCredentials } from './types';
|
||||
export function createCredentialsWithServicePrincipal(
|
||||
sub: string,
|
||||
token?: string,
|
||||
accessRestrictions?: BackstagePrincipalAccessRestrictions,
|
||||
): InternalBackstageCredentials<BackstageServicePrincipal> {
|
||||
return {
|
||||
$$type: '@backstage/BackstageCredentials',
|
||||
@@ -33,6 +35,7 @@ export function createCredentialsWithServicePrincipal(
|
||||
principal: {
|
||||
type: 'service',
|
||||
subject: sub,
|
||||
accessRestrictions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @backstage/backend-common
|
||||
|
||||
## 0.23.0-next.1
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 02103be: Deprecated and moved over core services to `@backstage/backend-defaults`
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/backend-app-api@0.7.6-next.1
|
||||
- @backstage/backend-plugin-api@0.6.19-next.1
|
||||
- @backstage/config-loader@1.8.0
|
||||
- @backstage/plugin-auth-node@0.4.14-next.1
|
||||
|
||||
## 0.22.1-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -17,11 +17,12 @@ import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { BitbucketCloudIntegration } from '@backstage/integration';
|
||||
import { BitbucketIntegration } from '@backstage/integration';
|
||||
import { BitbucketServerIntegration } from '@backstage/integration';
|
||||
import { CacheService as CacheClient } from '@backstage/backend-plugin-api';
|
||||
import { CacheServiceOptions as CacheClientOptions } from '@backstage/backend-plugin-api';
|
||||
import { CacheServiceSetOptions as CacheClientSetOptions } from '@backstage/backend-plugin-api';
|
||||
import { CacheService } from '@backstage/backend-plugin-api';
|
||||
import { CacheServiceOptions } from '@backstage/backend-plugin-api';
|
||||
import type { CacheServiceSetOptions } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import cors from 'cors';
|
||||
import { DiscoveryService } from '@backstage/backend-plugin-api';
|
||||
import Docker from 'dockerode';
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
@@ -44,7 +45,6 @@ import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { MergeResult } from 'isomorphic-git';
|
||||
import { PermissionsService } from '@backstage/backend-plugin-api';
|
||||
import { DatabaseService as PluginDatabaseManager } from '@backstage/backend-plugin-api';
|
||||
import { DiscoveryService as PluginEndpointDiscovery } from '@backstage/backend-plugin-api';
|
||||
import { PluginMetadataService } from '@backstage/backend-plugin-api';
|
||||
import { PushResult } from 'isomorphic-git';
|
||||
import { Readable } from 'stream';
|
||||
@@ -193,18 +193,26 @@ export class BitbucketUrlReader implements UrlReader {
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
export { CacheClient };
|
||||
// @public @deprecated (undocumented)
|
||||
export type CacheClient = CacheService;
|
||||
|
||||
export { CacheClientOptions };
|
||||
// @public @deprecated (undocumented)
|
||||
export type CacheClientOptions = CacheServiceOptions;
|
||||
|
||||
export { CacheClientSetOptions };
|
||||
// @public @deprecated (undocumented)
|
||||
export type CacheClientSetOptions = CacheServiceSetOptions;
|
||||
|
||||
// @public
|
||||
export class CacheManager {
|
||||
forPlugin(pluginId: string): PluginCacheManager;
|
||||
forPlugin(pluginId: string): {
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
};
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: CacheManagerOptions,
|
||||
options?: {
|
||||
logger?: LoggerService;
|
||||
onError?: (err: Error) => void;
|
||||
},
|
||||
): CacheManager;
|
||||
}
|
||||
|
||||
@@ -214,10 +222,10 @@ export type CacheManagerOptions = {
|
||||
onError?: (err: Error) => void;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export function cacheToPluginCacheManager(
|
||||
cache: CacheClient,
|
||||
): PluginCacheManager;
|
||||
// @public
|
||||
export function cacheToPluginCacheManager(cache: CacheService): {
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export const coloredFormat: winston.Logform.Format;
|
||||
@@ -575,10 +583,10 @@ export const legacyPlugin: (
|
||||
default: LegacyCreateRouter<
|
||||
TransformedEnv<
|
||||
{
|
||||
cache: CacheClient;
|
||||
cache: CacheService;
|
||||
config: RootConfigService;
|
||||
database: PluginDatabaseManager;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
discovery: DiscoveryService;
|
||||
logger: LoggerService;
|
||||
permissions: PermissionsService;
|
||||
scheduler: SchedulerService;
|
||||
@@ -588,7 +596,9 @@ export const legacyPlugin: (
|
||||
},
|
||||
{
|
||||
logger: (log: LoggerService) => Logger;
|
||||
cache: (cache: CacheClient) => PluginCacheManager;
|
||||
cache: (cache: CacheService) => {
|
||||
getClient(options?: CacheServiceOptions | undefined): CacheService;
|
||||
};
|
||||
}
|
||||
>
|
||||
>;
|
||||
@@ -639,12 +649,13 @@ export function notFoundHandler(): RequestHandler;
|
||||
// @public (undocumented)
|
||||
export interface PluginCacheManager {
|
||||
// (undocumented)
|
||||
getClient(options?: CacheClientOptions): CacheClient;
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
}
|
||||
|
||||
export { PluginDatabaseManager };
|
||||
|
||||
export { PluginEndpointDiscovery };
|
||||
// @public @deprecated (undocumented)
|
||||
export type PluginEndpointDiscovery = DiscoveryService;
|
||||
|
||||
// @public
|
||||
export interface PullOptions {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/backend-common",
|
||||
"version": "0.22.1-next.0",
|
||||
"version": "0.23.0-next.1",
|
||||
"description": "Common functionality library for Backstage backends",
|
||||
"backstage": {
|
||||
"role": "node-library"
|
||||
|
||||
@@ -1,384 +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 { ConfigReader } from '@backstage/config';
|
||||
import Keyv from 'keyv';
|
||||
import KeyvMemcache from '@keyv/memcache';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
import { DefaultCacheClient } from './CacheClient';
|
||||
import { CacheManager } from './CacheManager';
|
||||
|
||||
jest.createMockFromModule('keyv');
|
||||
jest.mock('keyv');
|
||||
jest.createMockFromModule('@keyv/memcache');
|
||||
jest.mock('@keyv/memcache');
|
||||
jest.createMockFromModule('@keyv/redis');
|
||||
jest.mock('@keyv/redis');
|
||||
jest.mock('./CacheClient', () => {
|
||||
return {
|
||||
DefaultCacheClient: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const globalDefaultTtl = 1234;
|
||||
describe('CacheManager', () => {
|
||||
const defaultConfigOptions = {
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'memory',
|
||||
defaultTtl: globalDefaultTtl,
|
||||
},
|
||||
},
|
||||
};
|
||||
const defaultConfig = () => new ConfigReader(defaultConfigOptions);
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
describe('CacheManager.fromConfig', () => {
|
||||
it('accesses the backend.cache key', () => {
|
||||
const getOptionalString = jest.fn();
|
||||
const getOptionalBoolean = jest.fn();
|
||||
const getOptionalNumber = jest.fn();
|
||||
const config = defaultConfig();
|
||||
config.getOptionalString = getOptionalString;
|
||||
config.getOptionalBoolean = getOptionalBoolean;
|
||||
config.getOptionalNumber = getOptionalNumber;
|
||||
|
||||
CacheManager.fromConfig(config);
|
||||
|
||||
expect(getOptionalString.mock.calls[0][0]).toEqual('backend.cache.store');
|
||||
expect(getOptionalString.mock.calls[1][0]).toEqual(
|
||||
'backend.cache.connection',
|
||||
);
|
||||
expect(getOptionalBoolean.mock.calls[0][0]).toEqual(
|
||||
'backend.cache.useRedisSets',
|
||||
);
|
||||
expect(getOptionalNumber.mock.calls[0][0]).toEqual(
|
||||
'backend.cache.defaultTtl',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not require the backend.cache key', () => {
|
||||
const config = new ConfigReader({ backend: {} });
|
||||
expect(() => {
|
||||
CacheManager.fromConfig(config);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws on unknown cache store', () => {
|
||||
const config = new ConfigReader({
|
||||
backend: { cache: { store: 'notreal' } },
|
||||
});
|
||||
expect(() => {
|
||||
CacheManager.fromConfig(config);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CacheManager.forPlugin', () => {
|
||||
const manager = CacheManager.fromConfig(defaultConfig());
|
||||
|
||||
it('connects to a cache store scoped to the plugin', async () => {
|
||||
const pluginId = 'test1';
|
||||
manager.forPlugin(pluginId).getClient();
|
||||
|
||||
const client = DefaultCacheClient as jest.Mock;
|
||||
expect(client).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('attaches error handler to client', () => {
|
||||
const pluginId = 'error-test';
|
||||
manager.forPlugin(pluginId).getClient();
|
||||
|
||||
const client = DefaultCacheClient as jest.Mock;
|
||||
const mockCalls = client.mock.calls.splice(-1);
|
||||
const realClient = mockCalls[0][0] as Keyv;
|
||||
expect(realClient.on).toHaveBeenCalledWith('error', expect.any(Function));
|
||||
});
|
||||
|
||||
it('provides different plugins different cache clients', async () => {
|
||||
const plugin1Id = 'test1';
|
||||
const plugin2Id = 'test2';
|
||||
const expectedTtl = 3600;
|
||||
manager.forPlugin(plugin1Id).getClient({ defaultTtl: expectedTtl });
|
||||
manager.forPlugin(plugin2Id).getClient({ defaultTtl: expectedTtl });
|
||||
|
||||
const client = DefaultCacheClient as jest.Mock;
|
||||
const cache = Keyv as unknown as jest.Mock;
|
||||
expect(cache).toHaveBeenCalledTimes(2);
|
||||
expect(client).toHaveBeenCalledTimes(2);
|
||||
|
||||
const plugin1CallArgs = cache.mock.calls[0];
|
||||
const plugin2CallArgs = cache.mock.calls[1];
|
||||
expect(plugin1CallArgs[0].namespace).not.toEqual(
|
||||
plugin2CallArgs[0].namespace,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CacheManager.forPlugin stores', () => {
|
||||
it('returns memory client when no cache is configured', () => {
|
||||
const manager = CacheManager.fromConfig(
|
||||
new ConfigReader({ backend: {} }),
|
||||
);
|
||||
const expectedTtl = 3600;
|
||||
const expectedNamespace = 'test-plugin';
|
||||
manager
|
||||
.forPlugin(expectedNamespace)
|
||||
.getClient({ defaultTtl: expectedTtl });
|
||||
|
||||
const cache = Keyv as unknown as jest.Mock;
|
||||
const mockCalls = cache.mock.calls.splice(-1);
|
||||
const callArgs = mockCalls[0];
|
||||
expect(callArgs[0]).toMatchObject({
|
||||
ttl: expectedTtl,
|
||||
namespace: expectedNamespace,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns memory client when explicitly configured', () => {
|
||||
const manager = CacheManager.fromConfig(defaultConfig());
|
||||
const expectedTtl = 3600;
|
||||
const expectedNamespace = 'test-plugin';
|
||||
manager
|
||||
.forPlugin(expectedNamespace)
|
||||
.getClient({ defaultTtl: expectedTtl });
|
||||
|
||||
const cache = Keyv as unknown as jest.Mock;
|
||||
const mockCalls = cache.mock.calls.splice(-1);
|
||||
const callArgs = mockCalls[0];
|
||||
expect(callArgs[0]).toMatchObject({
|
||||
ttl: expectedTtl,
|
||||
namespace: expectedNamespace,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns memory client with a global defaultTtl when explicitly configured', () => {
|
||||
const manager = CacheManager.fromConfig(defaultConfig());
|
||||
const expectedNamespace = 'test-plugin';
|
||||
manager.forPlugin(expectedNamespace).getClient();
|
||||
|
||||
const cache = Keyv as unknown as jest.Mock;
|
||||
const mockCalls = cache.mock.calls.splice(-1);
|
||||
const callArgs = mockCalls[0];
|
||||
expect(callArgs[0]).toMatchObject({
|
||||
ttl: globalDefaultTtl,
|
||||
namespace: expectedNamespace,
|
||||
});
|
||||
});
|
||||
|
||||
it('shares memory across multiple instances of the memory client', () => {
|
||||
const manager = CacheManager.fromConfig(defaultConfig());
|
||||
const plugin = 'test-plugin';
|
||||
|
||||
// Instantiate two in-memory clients.
|
||||
manager.forPlugin(plugin).getClient({ defaultTtl: 10 });
|
||||
manager.forPlugin(plugin).getClient({ defaultTtl: 10 });
|
||||
|
||||
const cache = Keyv as unknown as jest.Mock;
|
||||
const mockCall2 = cache.mock.calls.splice(-1)[0][0];
|
||||
const mockCall1 = cache.mock.calls.splice(-1)[0][0];
|
||||
|
||||
// Note: .toBe() checks referential identity of object instances.
|
||||
expect(mockCall1.store).toBe(mockCall2.store);
|
||||
});
|
||||
|
||||
it('returns a memcache client when configured', () => {
|
||||
const expectedHost = '127.0.0.1:11211';
|
||||
const manager = CacheManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'memcache',
|
||||
connection: expectedHost,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const expectedTtl = 3600;
|
||||
manager.forPlugin('test').getClient({ defaultTtl: expectedTtl });
|
||||
|
||||
const cache = Keyv as unknown as jest.Mock;
|
||||
const mockCacheCalls = cache.mock.calls.splice(-1);
|
||||
expect(mockCacheCalls[0][0]).toMatchObject({
|
||||
ttl: expectedTtl,
|
||||
});
|
||||
expect(mockCacheCalls[0][0].store).toBeInstanceOf(KeyvMemcache);
|
||||
const memcache = KeyvMemcache as unknown as jest.Mock;
|
||||
const mockMemcacheCalls = memcache.mock.calls.splice(-1);
|
||||
expect(mockMemcacheCalls[0][0]).toEqual(expectedHost);
|
||||
});
|
||||
|
||||
it('returns a memcache client with a global defaultTtl when configured', () => {
|
||||
const expectedHost = '127.0.0.1:11211';
|
||||
const manager = CacheManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'memcache',
|
||||
connection: expectedHost,
|
||||
defaultTtl: globalDefaultTtl,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
manager.forPlugin('test').getClient();
|
||||
|
||||
const cache = Keyv as unknown as jest.Mock;
|
||||
const mockCacheCalls = cache.mock.calls.splice(-1);
|
||||
expect(mockCacheCalls[0][0]).toMatchObject({
|
||||
ttl: globalDefaultTtl,
|
||||
});
|
||||
expect(mockCacheCalls[0][0].store).toBeInstanceOf(KeyvMemcache);
|
||||
const memcache = KeyvMemcache as unknown as jest.Mock;
|
||||
const mockMemcacheCalls = memcache.mock.calls.splice(-1);
|
||||
expect(mockMemcacheCalls[0][0]).toEqual(expectedHost);
|
||||
});
|
||||
|
||||
it('returns a Redis client when configured', () => {
|
||||
const redisConnection = 'redis://127.0.0.1:6379';
|
||||
const manager = CacheManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'redis',
|
||||
connection: redisConnection,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const expectedTtl = 3600;
|
||||
manager.forPlugin('test').getClient({ defaultTtl: expectedTtl });
|
||||
|
||||
const cache = Keyv as unknown as jest.Mock;
|
||||
const mockCacheCalls = cache.mock.calls.splice(-1);
|
||||
expect(mockCacheCalls[0][0]).toMatchObject({
|
||||
ttl: expectedTtl,
|
||||
});
|
||||
expect(mockCacheCalls[0][0].store).toBeInstanceOf(KeyvRedis);
|
||||
const redis = KeyvRedis as unknown as jest.Mock;
|
||||
const mockRedisCalls = redis.mock.calls.splice(-1);
|
||||
expect(mockRedisCalls[0][0]).toEqual(redisConnection);
|
||||
});
|
||||
|
||||
it('returns a Redis client with a global defaultTtl when configured', () => {
|
||||
const redisConnection = 'redis://127.0.0.1:6379';
|
||||
const manager = CacheManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'redis',
|
||||
connection: redisConnection,
|
||||
defaultTtl: globalDefaultTtl,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
manager.forPlugin('test').getClient();
|
||||
|
||||
const cache = Keyv as unknown as jest.Mock;
|
||||
const mockCacheCalls = cache.mock.calls.splice(-1);
|
||||
expect(mockCacheCalls[0][0]).toMatchObject({
|
||||
ttl: globalDefaultTtl,
|
||||
});
|
||||
expect(mockCacheCalls[0][0].store).toBeInstanceOf(KeyvRedis);
|
||||
const redis = KeyvRedis as unknown as jest.Mock;
|
||||
const mockRedisCalls = redis.mock.calls.splice(-1);
|
||||
expect(mockRedisCalls[0][0]).toEqual(redisConnection);
|
||||
});
|
||||
|
||||
it('returns a Redis client when configured with useRedisSets flag', () => {
|
||||
const redisConnection = 'redis://127.0.0.1:6379';
|
||||
const useRedisSets = false;
|
||||
const manager = CacheManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
cache: {
|
||||
store: 'redis',
|
||||
connection: redisConnection,
|
||||
useRedisSets: useRedisSets,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const expectedTtl = 3600;
|
||||
manager.forPlugin('test').getClient({ defaultTtl: expectedTtl });
|
||||
|
||||
const cache = Keyv as unknown as jest.Mock;
|
||||
const mockCacheCalls = cache.mock.calls.splice(-1);
|
||||
expect(mockCacheCalls[0][0]).toMatchObject({
|
||||
ttl: expectedTtl,
|
||||
useRedisSets: useRedisSets,
|
||||
});
|
||||
expect(mockCacheCalls[0][0].store).toBeInstanceOf(KeyvRedis);
|
||||
const redis = KeyvRedis as unknown as jest.Mock;
|
||||
const mockRedisCalls = redis.mock.calls.splice(-1);
|
||||
expect(mockRedisCalls[0][0]).toEqual(redisConnection);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection errors', () => {
|
||||
it('uses provided logger', () => {
|
||||
// Set up and inject mock logger.
|
||||
const mockLogger = { child: jest.fn(), error: jest.fn() };
|
||||
mockLogger.child.mockImplementation(() => mockLogger as any);
|
||||
const manager = CacheManager.fromConfig(defaultConfig(), {
|
||||
logger: mockLogger as any,
|
||||
});
|
||||
|
||||
// Set up a cache client using the configured manager.
|
||||
manager.forPlugin('error-logger-test').getClient();
|
||||
|
||||
// Retrieve the error handler attached to the cache client.
|
||||
const client = DefaultCacheClient as jest.Mock;
|
||||
const mockCalls = client.mock.calls.splice(-1);
|
||||
const realClient = mockCalls[0][0] as Keyv;
|
||||
const realOnError = realClient.on as jest.Mock;
|
||||
const realHandler = realOnError.mock.calls.splice(-1)[0][1];
|
||||
|
||||
// Invoke the actual error handler.
|
||||
const expectedError = new Error('some error');
|
||||
realHandler(expectedError);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'Failed to create cache client',
|
||||
expectedError,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls provided handler', () => {
|
||||
// Set up and inject mock logger.
|
||||
const mockHandler = jest.fn();
|
||||
const manager = CacheManager.fromConfig(defaultConfig(), {
|
||||
onError: mockHandler,
|
||||
});
|
||||
|
||||
// Set up a cache client using the configured manager.
|
||||
manager.forPlugin('error-handler-test').getClient();
|
||||
|
||||
// Retrieve the error handler attached to the cache client.
|
||||
const client = DefaultCacheClient as jest.Mock;
|
||||
const mockCalls = client.mock.calls.splice(-1);
|
||||
const realClient = mockCalls[0][0] as Keyv;
|
||||
const realOnError = realClient.on as jest.Mock;
|
||||
const realHandler = realOnError.mock.calls.splice(-1)[0][1];
|
||||
|
||||
// Invoke the actual error handler.
|
||||
const expectedError = new Error('some error');
|
||||
realHandler(expectedError);
|
||||
expect(mockHandler).toHaveBeenCalledWith(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 {
|
||||
CacheService,
|
||||
CacheServiceOptions,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
|
||||
/**
|
||||
* Compatibility wrapper for going from a new-backend cache service to the
|
||||
* old-backend plugin cache manager.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function cacheToPluginCacheManager(cache: CacheService): {
|
||||
getClient(options?: CacheServiceOptions): CacheService;
|
||||
} {
|
||||
return {
|
||||
getClient: (opts: CacheServiceOptions) => cache.withOptions(opts),
|
||||
};
|
||||
}
|
||||
+3
-8
@@ -14,11 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { CacheManager, cacheToPluginCacheManager } from './CacheManager';
|
||||
export type {
|
||||
CacheClient,
|
||||
CacheClientSetOptions,
|
||||
PluginCacheManager,
|
||||
CacheManagerOptions,
|
||||
CacheClientOptions,
|
||||
} from './types';
|
||||
export { cacheToPluginCacheManager } from './cacheToPluginCacheManager';
|
||||
export * from './reexport';
|
||||
export * from './types';
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/*
|
||||
* NOTE(freben): This is a temporary hack. We use cross-package imports so that
|
||||
* we do not have to maintain double implementations for the time being, until
|
||||
* backend-common is properly removed.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
export { CacheManager } from '../../../backend-defaults/src/entrypoints/cache/CacheManager';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
export {
|
||||
type PluginCacheManager,
|
||||
type CacheManagerOptions,
|
||||
} from '../../../backend-defaults/src/entrypoints/cache/types';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user