diff --git a/README.md b/README.md index a27fe58..12121e2 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,21 @@ The tenants and their configuration be found in [./src/data/user/tenants.ts](./s Some resources in the ORD Reference App are system instance aware. When fetching the metadata, we need to select for which tenant we need the information. -Therefore we defined custom [Access Strategies](https://open-resource-discovery.github.io/specification/spec-v1/interfaces/document#api-resource-definition_accessstrategies) how the ORD information and the related metadata can be accessed. +The system-instance ORD document uses the standard `basic-auth` [Access Strategy](https://open-resource-discovery.github.io/specification/spec-extensions/access-strategies/basic-auth). +The application infers the tenant from the authenticated user, so no separate tenant selector is needed. +For example, user `foo` belongs to tenant `T1`, while user `bar` belongs to tenant `T2`. + +Other tenant-aware resource definitions demonstrate custom open access strategies that select a tenant through an HTTP header. To see some examples how the access strategies are used, have a look at [./docs/http/CRM_API.http](./docs/http/CRM_API.http) and [./docs/http/ORD_Document_API.http](./docs/http/ORD_Document_API.http). They contain documented example requests and are executable through the [REST Client VSCode Extension](https://marketplace.visualstudio.com/items?itemName=humao.rest-client). +### `basic-auth` + +The system-instance ORD document is protected with Basic Auth. +Its tenant context is derived from the authenticated user. +For example, authenticate as `foo` with password `bar` to retrieve the document for tenant `T1`. + ### `sap.foo.bar:open-global-tenant-id:v1` The metadata is openly accessible and is system instance aware. @@ -96,4 +106,4 @@ If you miss some features or use case, please get in contact. ## License -Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/open-resource-discovery/reference-application). \ No newline at end of file +Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/open-resource-discovery/reference-application). diff --git a/docs/http/ORD_Document_API.http b/docs/http/ORD_Document_API.http index 1a1eb49..045d0c0 100644 --- a/docs/http/ORD_Document_API.http +++ b/docs/http/ORD_Document_API.http @@ -10,6 +10,7 @@ # TenantIDs for Tenant T1 @localTenantId = T1 @sapGlobalTenantId = 740000101 +@apiAuthorization = Basic Zm9vOmJhcg== # TenantIDs for Tenant T2 # @localTenantId = T2 @@ -30,37 +31,18 @@ content-type: application/json # Second Step (1/2) # * REPEAT for each discovered ORD Document: # * GET the ORD document according to the `accessType` (here: `open`) -GET {{apiBasePath}}/open-resource-discovery/v1/documents/1 HTTP/1.1 +GET {{apiBasePath}}/open-resource-discovery/v1/documents/system-version HTTP/1.1 content-type: application/json ################################################################################# # Second Step (2/2) # * REPEAT for each discovered ORD Document: -# * GET the ORD document according to the `accessType` (here: `custom`) -# * REPEAT this per tenant / system instance because `systemInstanceAware`: true - -# We have three choices: - -# 1) Provide no tenant ID, then we only get a system instance unaware ORD document back - -GET {{apiBasePath}}/open-resource-discovery/v1/documents/2 HTTP/1.1 -content-type: application/json - -################################################################################# - -# 2) Provide global tenant ID that needs to be mapped by this application to its local tenant id -GET {{apiBasePath}}/open-resource-discovery/v1/documents/2 HTTP/1.1 -content-type: application/json -global-tenant-id: {{sapGlobalTenantId}} - -################################################################################# - -# 3) Provide local tenant ID and use it directly - -GET {{apiBasePath}}/open-resource-discovery/v1/documents/2 HTTP/1.1 +# * GET the ORD document according to the `accessType` (here: `basic-auth`) +# * The authenticated user determines the tenant / system instance. +GET {{apiBasePath}}/open-resource-discovery/v1/documents/system-instance HTTP/1.1 content-type: application/json -local-tenant-id: {{localTenantId}} +Authorization: {{apiAuthorization}} ################################################################################# diff --git a/package-lock.json b/package-lock.json index 708d6e9..ef696f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "uuid": "^11.1.0" }, "devDependencies": { - "@open-resource-discovery/specification": "^1.12.0", + "@open-resource-discovery/specification": "^1.12.1", "@sap/eslint-config": "^0.4.0", "@tsconfig/node20": "20.1.6", "@types/jest": "^30.0.0", @@ -28,6 +28,7 @@ "@types/uuid": "^10.0.0", "eslint": "^9.30.1", "jest": "^30.0.4", + "jest-util": "29.7.0", "openapi-types": "^12.1.3", "prettier": "3.6.2", "rimraf": "^6.0.1", @@ -2844,9 +2845,9 @@ } }, "node_modules/@open-resource-discovery/specification": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@open-resource-discovery/specification/-/specification-1.12.0.tgz", - "integrity": "sha512-kAg5EsBJ2yMmZ6BTuAFi/pv6uO4SLrFYKs1stz/tqlK44RR5sdNarz9J0EZ7NDQk+C9H1cq8aW13ERa6Ot2y4w==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@open-resource-discovery/specification/-/specification-1.12.1.tgz", + "integrity": "sha512-XOkdha/mlnnpq+WGdTJR2RRZ3rzfqS2Y4S1kWvl1fz7ALvAM68iC7GwroTBekN7VA+LejVcFPJJuCysTvXrMpg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -7511,6 +7512,24 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-validate": { "version": "30.0.2", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.0.2.tgz", diff --git a/package.json b/package.json index 76cadad..85837e1 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "uuid": "^11.1.0" }, "devDependencies": { - "@open-resource-discovery/specification": "^1.12.0", + "@open-resource-discovery/specification": "^1.12.1", "@sap/eslint-config": "^0.4.0", "@tsconfig/node20": "20.1.6", "@types/jest": "^30.0.0", @@ -46,6 +46,7 @@ "@types/uuid": "^10.0.0", "eslint": "^9.30.1", "jest": "^30.0.4", + "jest-util": "29.7.0", "openapi-types": "^12.1.3", "prettier": "3.6.2", "rimraf": "^6.0.1", diff --git a/src/__tests__/server.integration.test.ts b/src/__tests__/server.integration.test.ts index 7dd7b7c..5e869a3 100644 --- a/src/__tests__/server.integration.test.ts +++ b/src/__tests__/server.integration.test.ts @@ -141,6 +141,10 @@ describe('Server Integration Tests', () => { }) describe('ORD Document API Integration', () => { + const tenantT1Credentials = Buffer.from('foo:bar').toString('base64') + const tenantT2Credentials = Buffer.from('bar:foo').toString('base64') + const invalidCredentials = Buffer.from('invalid:credentials').toString('base64') + it('should return ORD configuration', async () => { const response = await app.inject({ method: 'GET', @@ -148,11 +152,19 @@ describe('Server Integration Tests', () => { }) expect(response.statusCode).toBe(200) - const body = JSON.parse(response.payload) as { value: { id: string; name: string }[] } + const body = JSON.parse(response.payload) as { + openResourceDiscoveryV1: { documents: { perspective?: string; accessStrategies: { type: string }[] }[] } + } expect(body).toHaveProperty('openResourceDiscoveryV1') + expect(body.openResourceDiscoveryV1.documents).toContainEqual( + expect.objectContaining({ + perspective: 'system-instance', + accessStrategies: [{ type: 'basic-auth' }], + }), + ) }) - it('should return static system-instance perspective ORD document', async () => { + it('should return static system-version perspective ORD document without authentication', async () => { const response = await app.inject({ method: 'GET', url: '/open-resource-discovery/v1/documents/system-version', @@ -163,19 +175,58 @@ describe('Server Integration Tests', () => { expect(body).toHaveProperty('openResourceDiscovery') }) - it('should return tenant-aware, system-instance ORD document', async () => { + it('should require authentication for the system-instance ORD document', async () => { + const response = await app.inject({ + method: 'GET', + url: '/open-resource-discovery/v1/documents/system-instance', + }) + + expect(response.statusCode).toBe(401) + }) + + it('should reject invalid credentials for the system-instance ORD document', async () => { const response = await app.inject({ method: 'GET', url: '/open-resource-discovery/v1/documents/system-instance', headers: { - 'local-tenant-id': 'T1', + Authorization: `Basic ${invalidCredentials}`, + }, + }) + + expect(response.statusCode).toBe(401) + }) + + it.each([ + ['T1', tenantT1Credentials], + ['T2', tenantT2Credentials], + ])('should infer tenant %s from Basic Auth', async (tenantId, credentials) => { + const response = await app.inject({ + method: 'GET', + url: '/open-resource-discovery/v1/documents/system-instance', + headers: { + Authorization: `Basic ${credentials}`, }, }) expect(response.statusCode).toBe(200) const body = JSON.parse(response.payload) as { description: string } expect(body).toHaveProperty('openResourceDiscovery') + expect(body.description).toContain(tenantId) + }) + + it('should not let a query parameter override the authenticated tenant', async () => { + const response = await app.inject({ + method: 'GET', + url: '/open-resource-discovery/v1/documents/system-instance?local-tenant-id=T2', + headers: { + Authorization: `Basic ${tenantT1Credentials}`, + }, + }) + + expect(response.statusCode).toBe(200) + const body = JSON.parse(response.payload) as { description: string } expect(body.description).toContain('T1') + expect(body.description).not.toContain('T2') }) }) diff --git a/src/api/open-resource-discovery/v1/data/configuration.ts b/src/api/open-resource-discovery/v1/data/configuration.ts index f700f6f..eb76736 100644 --- a/src/api/open-resource-discovery/v1/data/configuration.ts +++ b/src/api/open-resource-discovery/v1/data/configuration.ts @@ -1,5 +1,5 @@ import { ORDConfiguration } from '@open-resource-discovery/specification' -import { customAccessStrategyLocalTenantId, customAccessStrategyGlobalTenantId, openAccessStrategy } from './shared.js' +import { basicAuthAccessStrategy, openAccessStrategy } from './shared.js' export const ordConfiguration: ORDConfiguration = { openResourceDiscoveryV1: { @@ -10,10 +10,10 @@ export const ordConfiguration: ORDConfiguration = { accessStrategies: [openAccessStrategy], perspective: 'system-version', }, - // Serve dynamic metadata, requires system / tenant headers and the correct access strategy + // Serve dynamic metadata for the tenant identified by Basic Auth { url: '/open-resource-discovery/v1/documents/system-instance', - accessStrategies: [customAccessStrategyGlobalTenantId, customAccessStrategyLocalTenantId], + accessStrategies: [basicAuthAccessStrategy], perspective: 'system-instance', }, ], diff --git a/src/api/open-resource-discovery/v1/data/shared.ts b/src/api/open-resource-discovery/v1/data/shared.ts index 6dcaea8..3159189 100644 --- a/src/api/open-resource-discovery/v1/data/shared.ts +++ b/src/api/open-resource-discovery/v1/data/shared.ts @@ -113,6 +113,14 @@ export const openAccessStrategy: AccessStrategy = { type: 'open', } +/** + * Resources using this strategy derive their tenant context from the + * authenticated user. + */ +export const basicAuthAccessStrategy: AccessStrategy = { + type: 'basic-auth', +} + /** * This is a custom access strategy that is specific to the ORD Reference application */ diff --git a/src/api/open-resource-discovery/v1/index.ts b/src/api/open-resource-discovery/v1/index.ts index 77d28cc..f639ab3 100644 --- a/src/api/open-resource-discovery/v1/index.ts +++ b/src/api/open-resource-discovery/v1/index.ts @@ -1,11 +1,11 @@ import { FastifyInstance } from 'fastify' +import { fastifyBasicAuth } from '@fastify/basic-auth' import fastifyETag from '@fastify/etag' -import { globalTenantIdToLocalTenantIdMapping } from '../../../data/user/tenantMapping.js' -import { getTenantIdsFromHeader } from '../../shared/validateUserAuthorization.js' +import { basicAuthConfig } from '../../shared/validateUserAuthorization.js' +import { UnauthorizedError } from '../../../error/UnauthorizedError.js' import { ordDocumentApiV1Config } from './config.js' import { ordConfiguration } from './data/configuration.js' import { getOrdDocumentForTenant, ordDocument } from './data/document.js' -import { CustomRequest } from '../../../types/types.js' export async function ordDocumentV1Api(fastify: FastifyInstance): Promise { fastify.log.info(`Registering ${ordDocumentApiV1Config.apiName}...`) @@ -13,6 +13,7 @@ export async function ordDocumentV1Api(fastify: FastifyInstance): Promise // Add support for ETag as RECOMMENDED by ORD and according to RFC2616-sec13 // @see https://github.com/fastify/fastify-etag await fastify.register(fastifyETag) + await fastify.register(fastifyBasicAuth, basicAuthConfig) // SYSTEM INSTANCE UNAWARE ORD information @@ -28,23 +29,17 @@ export async function ordDocumentV1Api(fastify: FastifyInstance): Promise // DYNAMIC (system instance perspective) ORD information - // Serve the unprotected, but system instance aware ORD Document #2 - // The result of this request will differ, depending on the tenant chosen - // We'll implement this as an ORD access strategy, where the tenant ID is passed via Header - // To show multiple options, we can offer both local tenant ID and global tenant ID for correlations - fastify.get(`/${ordDocumentApiV1Config.apiEntryPoint}/documents/system-instance`, (req: CustomRequest) => { - const tenantIds = getTenantIdsFromHeader(req) - - if (tenantIds.localTenantId) { - // This is the `sap.foo.bar:open-local-tenant-id:v1` access strategy - return getOrdDocumentForTenant(tenantIds.localTenantId) - } else if (tenantIds.globalTenantId) { - // This is the `sap.foo.bar:open-global-tenant-id:v1` access strategy - return getOrdDocumentForTenant(globalTenantIdToLocalTenantIdMapping[tenantIds.globalTenantId]) - } else { - throw new Error( - 'No tenant ID provided in the request header via local-tenant-id or global-tenant-id. Hint: for demo purposes it can be set in the query string as well, e.g. ?local-tenant-id=T1', - ) - } - }) + // Serve the protected, system instance aware ORD document. + // The authenticated user determines the tenant whose metadata is returned. + fastify.get( + `/${ordDocumentApiV1Config.apiEntryPoint}/documents/system-instance`, + { onRequest: fastify.basicAuth }, + (req) => { + if (!req.user?.tenantId) { + throw new UnauthorizedError('The authenticated user has no tenant assigned') + } + + return getOrdDocumentForTenant(req.user.tenantId) + }, + ) } diff --git a/static/index.html b/static/index.html index 65b06f6..e59ea63 100644 --- a/static/index.html +++ b/static/index.html @@ -140,8 +140,8 @@

  • /open-resource-discovery/v1/documents/system-instance?local-tenant-id=T1
    - (dynamic, system-instance perspective) + href="/open-resource-discovery/v1/documents/system-instance">/open-resource-discovery/v1/documents/system-instance
    + (dynamic, system-instance perspective; Basic Auth required, e.g. foo/bar for T1)
  • @@ -156,4 +156,4 @@

    - \ No newline at end of file +