diff --git a/README.md b/README.md index fe19ad1..e8d9cdf 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,11 @@ MODE_PROXY=1 MODE_PS=1 # Optional: if set, POST / requires X-API-Key to match this value POLICY_SERVER_API_KEY=API_KEY_EXAMPLE +# Optional: if set, GET/POST /node-access-list require this separate X-API-Key +POLICY_SERVER_NODE_ACCESS_LIST_API_KEY=ACCESS_LIST_API_KEY_EXAMPLE +# Comma-separated list of allowed Ocean Node addresses in `0x` + 40 hex format. +# If this is empty or unset, node access-list authorization is disabled. +POLICY_SERVER_NODE_ACCESS_LIST=0x1111111111111111111111111111111111111111,0x2222222222222222222222222222222222222222 ``` 1. Start the Docker container: @@ -40,6 +45,34 @@ POLICY_SERVER_API_KEY=API_KEY_EXAMPLE `POLICY_SERVER_API_KEY` is optional. If it is configured, requests to `POST /` must include `X-API-Key: `, and missing or invalid keys are rejected with `401 Unauthorized` before any action is processed. If it is not configured, the Policy Server accepts requests without API key authentication. +`POLICY_SERVER_NODE_ACCESS_LIST_API_KEY` is separate. If it is configured, requests to `GET /node-access-list` and `POST /node-access-list` must include `X-API-Key: `. + +When `POLICY_SERVER_NODE_ACCESS_LIST` contains one or more addresses, Ocean Node caller authorization on `POST /` is enabled. Every Ocean Node action request must include: + +- `nodeAddress` + +`nodeAddress` must match the `0x` + 40 hex address format and must be present in `POLICY_SERVER_NODE_ACCESS_LIST`. + +The API key can also protect access-list management endpoints: + +- `GET /node-access-list` +- `POST /node-access-list` + +These endpoints use `POLICY_SERVER_NODE_ACCESS_LIST_API_KEY`, not `POLICY_SERVER_API_KEY`. + +`POST /node-access-list` accepts a JSON body like: + +```json +{ + "addresses": [ + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222" + ] +} +``` + +This replaces the in-memory list for the running server process and mirrors the values into `process.env`. Sending an empty array disables env-based node access-list authorization for newly created app instances. + - `initiate` - `getPD` - `presentationRequest` @@ -65,6 +98,7 @@ POLICY_SERVER_API_KEY=API_KEY_EXAMPLE { "action": "initiate", "serviceId": "ff294c2e2c7d01bd5f9701abc117737917bb1f91044ba6b2d0903fc806db0d65", + "nodeAddress": "0xd727fb9be39fa019d7c02fea19e54d688da3a662", "consumerAddress": "0xd727fb9be39fa019d7c02fea19e54d688da3a662", "policyServer": { "sessionId": "", @@ -565,6 +599,7 @@ Policy Server also replaces `response_uri` with `WALTID_VERIFY_RESPONSE_REDIRECT { "action": "download", "serviceId": "ff294c2e2c7d01bd5f9701abc117737917bb1f91044ba6b2d0903fc806db0d65", + "nodeAddress": "0xd727fb9be39fa019d7c02fea19e54d688da3a662", "consumerAddress": "0xd727fb9be39fa019d7c02fea19e54d688da3a662", "policyServer": { "successRedirectUri": "", @@ -920,6 +955,7 @@ Policy Server also replaces `response_uri` with `WALTID_VERIFY_RESPONSE_REDIRECT "action": "startCompute", "documentId": "did1", "serviceId": "ff294c2e2c7d01bd5f9701abc117737917bb1f91044ba6b2d0903fc806db0d65", + "nodeAddress": "0xd727fb9be39fa019d7c02fea19e54d688da3a662", "consumerAddress": "0xd727fb9be39fa019d7c02fea19e54d688da3a662", "policyServer": [ { diff --git a/package-lock.json b/package-lock.json index 7199f00..4fe6385 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@types/mocha": "^10.0.10", "@types/node": "^22.9.0", "@types/sinon": "^17.0.3", + "@types/supertest": "^7.2.0", "@types/swagger-ui-express": "^4.1.7", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", @@ -484,6 +485,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -538,6 +546,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -623,6 +638,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/swagger-ui-express": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.7.tgz", diff --git a/package.json b/package.json index bd8e94a..75634f3 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/mocha": "^10.0.10", "@types/node": "^22.9.0", "@types/sinon": "^17.0.3", + "@types/supertest": "^7.2.0", "@types/swagger-ui-express": "^4.1.7", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", diff --git a/src/@types/policy.ts b/src/@types/policy.ts index 382ecab..b4ab1ec 100644 --- a/src/@types/policy.ts +++ b/src/@types/policy.ts @@ -1,5 +1,6 @@ export type PolicyRequestPayload = { action: string + nodeAddress?: string } & Record export type PolicyRequestResponse = { diff --git a/src/index.ts b/src/index.ts index 3de9545..db61aa3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,19 +12,32 @@ import { downloadLogs } from './utils/logger.js' import dotenv from 'dotenv' import path from 'path' import { fileURLToPath } from 'url' -import { policyServerApiKeyAuth } from './utils/auth.js' +import { nodeAccessListApiKeyAuth, policyServerApiKeyAuth } from './utils/auth.js' +import { + EnvNodeAccessListStore, + NodeRequestAuthenticator, + normalizeNodeAddresses +} from './utils/nodeRequestAuth.js' dotenv.config() -export function createApp(): express.Express { +export function createApp( + nodeRequestAuthenticator: NodeRequestAuthenticator = NodeRequestAuthenticator.fromEnvironment() +): express.Express { const app = express() const authType = process.env.AUTH_TYPE || 'waltid' + const nodeAccessListStore = EnvNodeAccessListStore.fromEnvironment() async function handlePolicyRequest( req: Request<{}, {}, PolicyRequestPayload>, res: Response ): Promise { const { action, ...rest } = req.body + const authFailure = await nodeRequestAuthenticator.authenticate(req.body) + if (authFailure) { + res.status(authFailure.httpStatus).json(authFailure) + return + } const handler = PolicyHandlerFactory.createPolicyHandler(authType) if (handler == null) { @@ -44,6 +57,52 @@ export function createApp(): express.Express { app.use(express.json()) if (process.env.MODE_PS === '1') { + app.get( + '/node-access-list', + nodeAccessListApiKeyAuth, + (_req: Request, res: Response) => { + res.status(200).json({ + success: true, + httpStatus: 200, + addresses: nodeAccessListStore.getAddresses() + }) + } + ) + + app.post( + '/node-access-list', + nodeAccessListApiKeyAuth, + (req: Request<{}, {}, { addresses?: string[] }>, res: Response) => { + const { addresses } = req.body ?? {} + + try { + if (!Array.isArray(addresses)) { + res.status(400).json({ + success: false, + httpStatus: 400, + message: 'addresses must be an array of Ethereum addresses.' + }) + return + } + + nodeAccessListStore.setAddresses(normalizeNodeAddresses(addresses)) + } catch { + res.status(400).json({ + success: false, + httpStatus: 400, + message: 'Invalid Ethereum address in access list.' + }) + return + } + + res.status(200).json({ + success: true, + httpStatus: 200, + addresses: nodeAccessListStore.getAddresses() + }) + } + ) + app.post( '/', policyServerApiKeyAuth, diff --git a/src/test/.env.test b/src/test/.env.test index 93af377..01607a1 100644 --- a/src/test/.env.test +++ b/src/test/.env.test @@ -11,3 +11,5 @@ ENABLE_LOGS=1 MODE_PROXY=1 MODE_PS=1 POLICY_SERVER_API_KEY=test-secret +POLICY_SERVER_NODE_ACCESS_LIST_API_KEY=list-secret +POLICY_SERVER_NODE_ACCESS_LIST=0x1111111111111111111111111111111111111111 diff --git a/src/test/nodeRequestAuth.test.ts b/src/test/nodeRequestAuth.test.ts new file mode 100644 index 0000000..32182b9 --- /dev/null +++ b/src/test/nodeRequestAuth.test.ts @@ -0,0 +1,232 @@ +/* eslint-disable no-unused-expressions */ +import { expect } from 'chai' +import request from 'supertest' +import sinon from 'sinon' +import { createApp } from '../index.js' +import { + EnvNodeAccessListStore, + NodeRequestAuthenticator, + type AccessListAuthorizer +} from '../utils/nodeRequestAuth.js' +import { PolicyHandlerFactory } from '../policyHandlerFactory.js' + +const allowedNodeAddress = '0x1111111111111111111111111111111111111111' + +describe('NodeRequestAuthenticator', () => { + const originalModePs = process.env.MODE_PS + const originalPolicyApiKey = process.env.POLICY_SERVER_API_KEY + const originalNodeAccessList = process.env.POLICY_SERVER_NODE_ACCESS_LIST + const testPolicyApiKey = process.env.POLICY_SERVER_API_KEY ?? 'test-secret' + let executeStub: sinon.SinonStub + + beforeEach(() => { + process.env.MODE_PS = '1' + process.env.POLICY_SERVER_API_KEY = testPolicyApiKey + process.env.POLICY_SERVER_NODE_ACCESS_LIST = allowedNodeAddress + EnvNodeAccessListStore.resetSharedFromEnvironment() + executeStub = sinon.stub().resolves({ + success: true, + httpStatus: 200, + message: 'ok' + }) + sinon + .stub(PolicyHandlerFactory, 'createPolicyHandler') + .returns({ execute: executeStub } as any) + }) + + afterEach(() => { + sinon.restore() + process.env.MODE_PS = originalModePs + process.env.POLICY_SERVER_API_KEY = originalPolicyApiKey + process.env.POLICY_SERVER_NODE_ACCESS_LIST = originalNodeAccessList + EnvNodeAccessListStore.resetSharedFromEnvironment() + }) + + it('allows a valid authorized node', async () => { + const accessListAuthorizer: AccessListAuthorizer = { + isAllowed: () => true + } + const authenticator = new NodeRequestAuthenticator(true, accessListAuthorizer) + const app = createApp(authenticator) + const payload = { action: 'download', nodeAddress: allowedNodeAddress } + + const response = await request(app) + .post('/') + .set('x-api-key', testPolicyApiKey) + .send(payload) + + expect(response.status).to.equal(200) + expect(response.body.success).to.equal(true) + expect(executeStub.calledOnce).to.equal(true) + }) + + it('rejects a valid signature for a node outside the access list', async () => { + const accessListAuthorizer: AccessListAuthorizer = { + isAllowed: () => false + } + const authenticator = new NodeRequestAuthenticator(true, accessListAuthorizer) + const app = createApp(authenticator) + const payload = { action: 'download', nodeAddress: allowedNodeAddress } + + const response = await request(app) + .post('/') + .set('x-api-key', testPolicyApiKey) + .send(payload) + + expect(response.status).to.equal(403) + expect(response.body.message).to.include('not authorized') + expect(executeStub.called).to.equal(false) + }) + + it('rejects an invalid node address', async () => { + const accessListAuthorizer: AccessListAuthorizer = { + isAllowed: () => true + } + const authenticator = new NodeRequestAuthenticator(true, accessListAuthorizer) + const app = createApp(authenticator) + const payload = { action: 'download', nodeAddress: 'not-an-address' } + + const response = await request(app) + .post('/') + .set('x-api-key', testPolicyApiKey) + .send(payload) + + expect(response.status).to.equal(401) + expect(response.body.message).to.equal('Invalid nodeAddress format.') + expect(executeStub.called).to.equal(false) + }) + + it('rejects requests with missing auth fields', async () => { + const accessListAuthorizer: AccessListAuthorizer = { + isAllowed: () => true + } + const authenticator = new NodeRequestAuthenticator(true, accessListAuthorizer) + const app = createApp(authenticator) + + const response = await request(app) + .post('/') + .set('x-api-key', testPolicyApiKey) + .send({ action: 'download' }) + + expect(response.status).to.equal(401) + expect(response.body.message).to.include('Missing node authorization fields') + expect(executeStub.called).to.equal(false) + }) +}) + +describe('Node access list management API', () => { + const originalModePs = process.env.MODE_PS + const originalNodeAccessListApiKey = process.env.POLICY_SERVER_NODE_ACCESS_LIST_API_KEY + const originalNodeAccessList = process.env.POLICY_SERVER_NODE_ACCESS_LIST + const testNodeAccessListApiKey = + process.env.POLICY_SERVER_NODE_ACCESS_LIST_API_KEY ?? 'list-secret' + + beforeEach(() => { + process.env.MODE_PS = '1' + process.env.POLICY_SERVER_NODE_ACCESS_LIST_API_KEY = testNodeAccessListApiKey + process.env.POLICY_SERVER_NODE_ACCESS_LIST = + '0x1111111111111111111111111111111111111111' + EnvNodeAccessListStore.resetSharedFromEnvironment() + }) + + afterEach(() => { + process.env.MODE_PS = originalModePs + process.env.POLICY_SERVER_NODE_ACCESS_LIST_API_KEY = originalNodeAccessListApiKey + process.env.POLICY_SERVER_NODE_ACCESS_LIST = originalNodeAccessList + EnvNodeAccessListStore.resetSharedFromEnvironment() + }) + + it('returns the current access list', async () => { + const app = createApp() + + const response = await request(app) + .get('/node-access-list') + .set('x-api-key', testNodeAccessListApiKey) + + expect(response.status).to.equal(200) + expect(response.body.addresses).to.deep.equal([ + '0x1111111111111111111111111111111111111111' + ]) + }) + + it('updates the access list', async () => { + const app = createApp() + + const response = await request(app) + .post('/node-access-list') + .set('x-api-key', testNodeAccessListApiKey) + .send({ + addresses: ['0x2222222222222222222222222222222222222222'] + }) + + expect(response.status).to.equal(200) + expect(response.body.addresses).to.deep.equal([ + '0x2222222222222222222222222222222222222222' + ]) + expect(process.env.POLICY_SERVER_NODE_ACCESS_LIST).to.equal( + '0x2222222222222222222222222222222222222222' + ) + }) + + it('applies updated access list entries to subsequent policy requests', async () => { + const originalPolicyApiKey = process.env.POLICY_SERVER_API_KEY + process.env.POLICY_SERVER_API_KEY = 'test-secret' + const app = createApp() + + await request(app) + .post('/node-access-list') + .set('x-api-key', testNodeAccessListApiKey) + .send({ + addresses: ['0x2222222222222222222222222222222222222222'] + }) + + const executeStub = sinon.stub().resolves({ + success: true, + httpStatus: 200, + message: 'ok' + }) + sinon + .stub(PolicyHandlerFactory, 'createPolicyHandler') + .returns({ execute: executeStub } as any) + + const response = await request(app).post('/').set('x-api-key', 'test-secret').send({ + action: 'download', + nodeAddress: '0x2222222222222222222222222222222222222222' + }) + + expect(response.status).to.equal(200) + expect(executeStub.calledOnce).to.equal(true) + + sinon.restore() + process.env.POLICY_SERVER_API_KEY = originalPolicyApiKey + }) + + it('rejects invalid access list payloads', async () => { + const app = createApp() + + const response = await request(app) + .post('/node-access-list') + .set('x-api-key', testNodeAccessListApiKey) + .send({ + addresses: ['not-an-address'] + }) + + expect(response.status).to.equal(400) + expect(response.body.message).to.equal('Invalid Ethereum address in access list.') + }) + + it('accepts an empty access list update', async () => { + const app = createApp() + + const response = await request(app) + .post('/node-access-list') + .set('x-api-key', testNodeAccessListApiKey) + .send({ + addresses: [] + }) + + expect(response.status).to.equal(200) + expect(response.body.addresses).to.deep.equal([]) + expect(process.env.POLICY_SERVER_NODE_ACCESS_LIST).to.equal('') + }) +}) diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 4dd94ef..ad49af1 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -29,33 +29,41 @@ function isApiKeyValid(providedApiKey: string, expectedApiKey: string): boolean return timingSafeEqual(providedBuffer, expectedBuffer) } -export const policyServerApiKeyAuth: RequestHandler = ( - req: Request, - res: Response, - next: NextFunction -): void => { - const expectedApiKey = process.env.POLICY_SERVER_API_KEY?.trim() - - if (!expectedApiKey) { +function createApiKeyAuth(envVarName: string, failureMessage: string): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + const expectedApiKey = process.env[envVarName]?.trim() + + if (!expectedApiKey) { + next() + return + } + + const providedApiKey = req.get(API_KEY_HEADER) + if (!providedApiKey || !isApiKeyValid(providedApiKey, expectedApiKey)) { + logWarn({ + method: req.method, + url: req.originalUrl, + headers: redactSensitiveHeaders(req.headers), + message: failureMessage + }) + res.status(401).json({ + success: false, + message: 'Unauthorized', + httpStatus: 401 + }) + return + } + next() - return } +} - const providedApiKey = req.get(API_KEY_HEADER) - if (!providedApiKey || !isApiKeyValid(providedApiKey, expectedApiKey)) { - logWarn({ - method: req.method, - url: req.originalUrl, - headers: redactSensitiveHeaders(req.headers), - message: 'Policy Server API key authentication failed.' - }) - res.status(401).json({ - success: false, - message: 'Unauthorized', - httpStatus: 401 - }) - return - } +export const policyServerApiKeyAuth = createApiKeyAuth( + 'POLICY_SERVER_API_KEY', + 'Policy Server API key authentication failed.' +) - next() -} +export const nodeAccessListApiKeyAuth = createApiKeyAuth( + 'POLICY_SERVER_NODE_ACCESS_LIST_API_KEY', + 'Node access list API key authentication failed.' +) diff --git a/src/utils/nodeRequestAuth.ts b/src/utils/nodeRequestAuth.ts new file mode 100644 index 0000000..fc99b1c --- /dev/null +++ b/src/utils/nodeRequestAuth.ts @@ -0,0 +1,145 @@ +import { PolicyRequestPayload, PolicyRequestResponse } from '../@types/policy.js' + +const NODE_ACCESS_LIST_ENV = 'POLICY_SERVER_NODE_ACCESS_LIST' +const NODE_ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/ + +export type AccessListAuthorizer = { + isAllowed(nodeAddress: string): boolean +} + +export class EnvNodeAccessListStore implements AccessListAuthorizer { + private addresses = new Map() + + public constructor(addresses = parseNodeAccessList(process.env[NODE_ACCESS_LIST_ENV])) { + this.setAddresses(addresses) + } + + public static fromEnvironment(): EnvNodeAccessListStore { + if (!sharedEnvNodeAccessListStore) { + sharedEnvNodeAccessListStore = new EnvNodeAccessListStore() + } + return sharedEnvNodeAccessListStore + } + + public static resetSharedFromEnvironment(): EnvNodeAccessListStore { + sharedEnvNodeAccessListStore = new EnvNodeAccessListStore() + return sharedEnvNodeAccessListStore + } + + public getAddresses(): string[] { + return Array.from(this.addresses.values()) + } + + public setAddresses(addresses: string[]): void { + this.addresses = new Map( + normalizeNodeAddresses(addresses).map((address) => [address.toLowerCase(), address]) + ) + process.env[NODE_ACCESS_LIST_ENV] = this.getAddresses().join(',') + } + + public isAllowed(nodeAddress: string): boolean { + if (this.addresses.size === 0) return true + return this.addresses.has(nodeAddress.toLowerCase()) + } +} + +export class NodeRequestAuthenticator { + private readonly accessListAuthorizer: AccessListAuthorizer + private readonly isEnabled: () => boolean + + public constructor( + enabled: boolean, + accessListAuthorizer: AccessListAuthorizer, + isEnabled: () => boolean = () => enabled + ) { + this.accessListAuthorizer = accessListAuthorizer + this.isEnabled = isEnabled + } + + public static fromEnvironment(): NodeRequestAuthenticator { + const accessListStore = EnvNodeAccessListStore.fromEnvironment() + + return new NodeRequestAuthenticator( + true, + accessListStore, + () => accessListStore.getAddresses().length > 0 + ) + } + + public async authenticate( + payload: PolicyRequestPayload + ): Promise { + if (!this.isEnabled()) return null + + const { action, nodeAddress } = payload + + if (!action) { + return this.buildFailureResponse(400, 'Missing action in request body.') + } + + if (!nodeAddress) { + return this.buildFailureResponse( + 401, + 'Missing node authorization fields. Expected nodeAddress.' + ) + } + + let normalizedAddress: string + try { + normalizedAddress = normalizeNodeAddress(nodeAddress) + } catch { + return this.buildFailureResponse(401, 'Invalid nodeAddress format.') + } + + try { + const allowed = await this.accessListAuthorizer.isAllowed(normalizedAddress) + if (!allowed) { + return this.buildFailureResponse( + 403, + `Node ${normalizedAddress} is not authorized by the configured access list.` + ) + } + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to validate node access list.' + return this.buildFailureResponse(500, message) + } + + return null + } + + private buildFailureResponse( + httpStatus: number, + message: string + ): PolicyRequestResponse { + return { + success: false, + message, + httpStatus + } + } +} + +export function parseNodeAccessList(rawValue: string | undefined): string[] { + if (!rawValue?.trim()) return [] + + return rawValue + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +export function normalizeNodeAddresses(addresses: string[]): string[] { + return addresses.map((address) => normalizeNodeAddress(address)) +} + +export function normalizeNodeAddress(address: string): string { + const trimmedAddress = address.trim() + if (!NODE_ADDRESS_PATTERN.test(trimmedAddress)) { + throw new Error('Invalid node address format.') + } + + return trimmedAddress.toLowerCase() +} + +let sharedEnvNodeAccessListStore: EnvNodeAccessListStore | undefined diff --git a/swagger.json b/swagger.json index 67e4528..8812086 100644 --- a/swagger.json +++ b/swagger.json @@ -38,6 +38,10 @@ ], "description": "Type of policy action to perform" }, + "nodeAddress": { + "type": "string", + "description": "Optional Ocean Node caller address. Required when POLICY_SERVER_NODE_ACCESS_LIST is configured." + }, "additionalProperties": { "type": "object", "description": "Additional parameters for the action" @@ -50,6 +54,7 @@ "value": { "action": "initiate", "serviceId": "ff294c2e2c7d01bd5f9701abc117737917bb1f91044ba6b2d0903fc806db0d65", + "nodeAddress": "0x1111111111111111111111111111111111111111", "consumerAddress": "0xd727fb9be39fa019d7c02fea19e54d688da3a662", "policyServer": { "sessionId": "", @@ -59,9 +64,7 @@ "presentationDefinitionUri": "" }, "ddo": { - "@context": [ - "https://www.w3.org/ns/credentials/v2" - ], + "@context": ["https://www.w3.org/ns/credentials/v2"], "id": "did:ope:1ec8435672854acf15ef3e61216900f314f8fae5e04e6b2fb0dc91c0579e0d02", "version": "5.0.0", "credentialSubject": { @@ -88,9 +91,7 @@ "type": "SSIpolicy" }, { - "values": [ - "*" - ], + "values": ["*"], "type": "address" } ], @@ -108,9 +109,7 @@ "@direction": "", "@language": "" }, - "tags": [ - "test" - ], + "tags": ["test"], "author": "", "license": { "name": "https://github.com/MBadea17/testdata/blob/af26d4f968fdb6e1882c2a3cca16a1480ca44a9c/License%20Agreement.pdf", @@ -313,9 +312,7 @@ } }, "additionalDdos": [], - "type": [ - "VerifiableCredential" - ], + "type": ["VerifiableCredential"], "issuer": "did:jwk:eyJrdHkiOiJPS1AiLCJkIjoiUnBVOU5ONmdFbGtvMXpjYnR1VVRERlVXWEZCeks1Um9FZ3FRaVFHMWN4QSIsImNydiI6IkVkMjU1MTkiLCJraWQiOiI1TS1od19JbTZDalJDZ3NCVXhGX0R2aWxRRnhfdVU5RWpNcUpPbzdQOERnIiwieCI6IklaeXo1WVl6WkpJYWN3R21ockstYXdCa2ZJWmRqbUFWWTViVjFIbGNxYjgifQ", "proof": { "signature": "N2GQRLQbDUM7gLlUNweF-JjP9XS1uAWHWZy-8NLdlBdPJFrdvVkZk1z6UntVqATkCZU-l8MMQP_5DyMDzws3DA", @@ -402,14 +399,14 @@ "summary": "getPD action, returns presentation definition", "value": { "action": "getPD", - "sessionId": "" + "sessionId": "" } }, "checkSessionId": { "summary": "Check Session Id action, returns presentation state object", "value": { "action": "checkSessionId", - "sessionId": "" + "sessionId": "" } }, "presentationRequest": { @@ -417,9 +414,9 @@ "value": { "action": "presentationRequest", "sessionId": "", - "vp_token": null, - "response": null, - "presentation_submission": null + "vp_token": null, + "response": null, + "presentation_submission": null } }, "download": { @@ -427,6 +424,7 @@ "value": { "action": "download", "serviceId": "ff294c2e2c7d01bd5f9701abc117737917bb1f91044ba6b2d0903fc806db0d65", + "nodeAddress": "0x1111111111111111111111111111111111111111", "consumerAddress": "0xd727fb9be39fa019d7c02fea19e54d688da3a662", "policyServer": { "successRedirectUri": "", @@ -436,9 +434,7 @@ "presentationDefinitionUri": "" }, "ddo": { - "@context": [ - "https://www.w3.org/ns/credentials/v2" - ], + "@context": ["https://www.w3.org/ns/credentials/v2"], "id": "did:ope:1ec8435672854acf15ef3e61216900f314f8fae5e04e6b2fb0dc91c0579e0d02", "version": "5.0.0", "credentialSubject": { @@ -465,9 +461,7 @@ "type": "SSIpolicy" }, { - "values": [ - "*" - ], + "values": ["*"], "type": "address" } ], @@ -485,9 +479,7 @@ "@direction": "", "@language": "" }, - "tags": [ - "test" - ], + "tags": ["test"], "author": "", "license": { "name": "https://github.com/MBadea17/testdata/blob/af26d4f968fdb6e1882c2a3cca16a1480ca44a9c/License%20Agreement.pdf", @@ -690,9 +682,7 @@ } }, "additionalDdos": [], - "type": [ - "VerifiableCredential" - ], + "type": ["VerifiableCredential"], "issuer": "did:jwk:eyJrdHkiOiJPS1AiLCJkIjoiUnBVOU5ONmdFbGtvMXpjYnR1VVRERlVXWEZCeks1Um9FZ3FRaVFHMWN4QSIsImNydiI6IkVkMjU1MTkiLCJraWQiOiI1TS1od19JbTZDalJDZ3NCVXhGX0R2aWxRRnhfdVU5RWpNcUpPbzdQOERnIiwieCI6IklaeXo1WVl6WkpJYWN3R21ockstYXdCa2ZJWmRqbUFWWTViVjFIbGNxYjgifQ", "proof": { "signature": "N2GQRLQbDUM7gLlUNweF-JjP9XS1uAWHWZy-8NLdlBdPJFrdvVkZk1z6UntVqATkCZU-l8MMQP_5DyMDzws3DA", @@ -781,20 +771,21 @@ "action": "startCompute", "documentId": "did1", "serviceId": "ff294c2e2c7d01bd5f9701abc117737917bb1f91044ba6b2d0903fc806db0d65", + "nodeAddress": "0x1111111111111111111111111111111111111111", "consumerAddress": "0xd727fb9be39fa019d7c02fea19e54d688da3a662", "policyServer": [ - { + { "documentId": "did1", - "serviceId": "ff294c2e2c7d01bd5f9701abc117737917bb1f91044ba6b2d0903fc806db0d65", + "serviceId": "ff294c2e2c7d01bd5f9701abc117737917bb1f91044ba6b2d0903fc806db0d65", "successRedirectUri": "", "sessionId": "", "errorRedirectUri": "", "responseRedirectUri": "", "presentationDefinitionUri": "" }, - { + { "documentId": "did2", - "serviceId": "service2", + "serviceId": "service2", "successRedirectUri": "", "sessionId": "", "errorRedirectUri": "", @@ -803,9 +794,7 @@ } ], "ddo": { - "@context": [ - "https://www.w3.org/ns/credentials/v2" - ], + "@context": ["https://www.w3.org/ns/credentials/v2"], "id": "did:ope:1ec8435672854acf15ef3e61216900f314f8fae5e04e6b2fb0dc91c0579e0d02", "version": "5.0.0", "credentialSubject": { @@ -832,9 +821,7 @@ "type": "SSIpolicy" }, { - "values": [ - "*" - ], + "values": ["*"], "type": "address" } ], @@ -852,9 +839,7 @@ "@direction": "", "@language": "" }, - "tags": [ - "test" - ], + "tags": ["test"], "author": "", "license": { "name": "https://github.com/MBadea17/testdata/blob/af26d4f968fdb6e1882c2a3cca16a1480ca44a9c/License%20Agreement.pdf", @@ -1057,9 +1042,7 @@ } }, "additionalDdos": [], - "type": [ - "VerifiableCredential" - ], + "type": ["VerifiableCredential"], "issuer": "did:jwk:eyJrdHkiOiJPS1AiLCJkIjoiUnBVOU5ONmdFbGtvMXpjYnR1VVRERlVXWEZCeks1Um9FZ3FRaVFHMWN4QSIsImNydiI6IkVkMjU1MTkiLCJraWQiOiI1TS1od19JbTZDalJDZ3NCVXhGX0R2aWxRRnhfdVU5RWpNcUpPbzdQOERnIiwieCI6IklaeXo1WVl6WkpJYWN3R21ockstYXdCa2ZJWmRqbUFWWTViVjFIbGNxYjgifQ", "proof": { "signature": "N2GQRLQbDUM7gLlUNweF-JjP9XS1uAWHWZy-8NLdlBdPJFrdvVkZk1z6UntVqATkCZU-l8MMQP_5DyMDzws3DA", @@ -1163,7 +1146,7 @@ "newDDO": { "summary": "New DDO action", "value": { - "action":"newDDO", + "action": "newDDO", "rawDDO": {}, "chainId": 1, "txId": "0x123", @@ -1173,7 +1156,7 @@ "updateDDO": { "summary": "Update DDO action", "value": { - "action":"updateDDO", + "action": "updateDDO", "rawDDO": {}, "chainId": 1, "txId": "0x123", @@ -1183,7 +1166,7 @@ "validateDDO": { "summary": "Validate DDO action", "value": { - "action":"updateDDO", + "action": "updateDDO", "rawDDO": {}, "chainId": 1, "txId": "0x123", @@ -1303,6 +1286,123 @@ } } } + }, + "/node-access-list": { + "get": { + "summary": "Get node access list", + "parameters": [ + { + "in": "header", + "name": "X-API-Key", + "required": false, + "schema": { + "type": "string" + }, + "description": "access-list admin secret." + } + ], + "responses": { + "200": { + "description": "Current node access list.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "httpStatus": { + "type": "integer" + }, + "addresses": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "summary": "Replace node access list", + "parameters": [ + { + "in": "header", + "name": "X-API-Key", + "required": false, + "schema": { + "type": "string" + }, + "description": "access-list admin secret." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["addresses"], + "properties": { + "addresses": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Full replacement list of allowed node addresses." + } + } + }, + "example": { + "addresses": [ + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Updated node access list.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "httpStatus": { + "type": "integer" + }, + "addresses": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid payload" + }, + "401": { + "description": "Unauthorized" + } + } + } } } }