Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
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).
30 changes: 6 additions & 24 deletions docs/http/ORD_Document_API.http
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# TenantIDs for Tenant T1
@localTenantId = T1
@sapGlobalTenantId = 740000101
@apiAuthorization = Basic Zm9vOmJhcg==

# TenantIDs for Tenant T2
# @localTenantId = T2
Expand All @@ -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}}

#################################################################################

Expand Down
27 changes: 23 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
59 changes: 55 additions & 4 deletions src/__tests__/server.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,18 +141,30 @@ 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',
url: '/.well-known/open-resource-discovery',
})

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',
Expand All @@ -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')
})
})

Expand Down
6 changes: 3 additions & 3 deletions src/api/open-resource-discovery/v1/data/configuration.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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',
},
],
Expand Down
8 changes: 8 additions & 0 deletions src/api/open-resource-discovery/v1/data/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
39 changes: 17 additions & 22 deletions src/api/open-resource-discovery/v1/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
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<void> {
fastify.log.info(`Registering ${ordDocumentApiV1Config.apiName}...`)

// 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

Expand All @@ -28,23 +29,17 @@ export async function ordDocumentV1Api(fastify: FastifyInstance): Promise<void>

// 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)
},
)
}
6 changes: 3 additions & 3 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ <h1 class="title">
</li>
<li>
<a
href="/open-resource-discovery/v1/documents/system-instance?local-tenant-id=T1">/open-resource-discovery/v1/documents/system-instance?local-tenant-id=T1</a><br />
<small>(dynamic, system-instance perspective)</small>
href="/open-resource-discovery/v1/documents/system-instance">/open-resource-discovery/v1/documents/system-instance</a><br />
<small>(dynamic, system-instance perspective; Basic Auth required, e.g. foo/bar for T1)</small>
</li>
</ul>
</ul>
Expand All @@ -156,4 +156,4 @@ <h1 class="title">
</section>
</body>

</html>
</html>
Loading