diff --git a/apps/node-message-broker/src/adapters/authz/authup-permission-gateway.ts b/apps/node-message-broker/src/adapters/authz/authup-permission-gateway.ts new file mode 100644 index 0000000..0b7da10 --- /dev/null +++ b/apps/node-message-broker/src/adapters/authz/authup-permission-gateway.ts @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { LRUCache } from 'lru-cache'; +import type { CallerIdentity, IPermissionCheckGateway } from '../../core/authz/index.ts'; + +/** A few seconds — bounds Hub load without letting a revoked grant linger. */ +const DEFAULT_TTL_MS = 5_000; + +const DEFAULT_MAX = 1_024; + +/** The slice of the Authup client this gateway uses — `POST /permissions/:id/check`. */ +type PermissionCheckClient = { + permission: { + check(idOrName: string, data?: Record): Promise<{ status: string }> + } +}; + +type AuthupPermissionGatewayContext = { + client: PermissionCheckClient, + /** result cache TTL; `<= 0` disables caching. Defaults to {@link DEFAULT_TTL_MS}. */ + cacheTtlMs?: number, + cacheMax?: number +}; + +/** + * Resolves a named permission for a caller against Authup's + * `POST /permissions/:id/check`, authenticated as the node client and passing the + * **caller's** identity in the body (the endpoint lets a body identity override the + * bearer). `status === 'success'` is a grant; anything else is a deny. + * + * Answers are cached briefly (shared across requests, keyed by permission + identity); + * in-flight checks are coalesced and transient failures are not cached. + */ +export class AuthupPermissionGateway implements IPermissionCheckGateway { + protected client: PermissionCheckClient; + + protected cache: LRUCache> | undefined; + + constructor(ctx: AuthupPermissionGatewayContext) { + this.client = ctx.client; + const ttl = ctx.cacheTtlMs ?? DEFAULT_TTL_MS; + const max = Math.max(1, ctx.cacheMax ?? DEFAULT_MAX); + // ttl <= 0 disables caching outright (an LRUCache with no ttl would cache forever). + this.cache = ttl > 0 ? new LRUCache>({ max, ttl }) : undefined; + } + + holds(permission: string, identity: CallerIdentity): Promise { + const { cache } = this; + if (!cache) { + return this.check(permission, identity); + } + + const key = this.buildKey(permission, identity); + + const cached = cache.get(key); + if (cached) { + return cached; + } + + const pending = this.check(permission, identity); + cache.set(key, pending); + // don't let a transient network/5xx failure stick in the cache + pending.catch(() => { + if (cache.peek(key) === pending) { + cache.delete(key); + } + }); + + return pending; + } + + protected async check(permission: string, identity: CallerIdentity): Promise { + const response = await this.client.permission.check(permission, { + identity: { + type: identity.type, + id: identity.id, + clientId: identity.clientId ?? null, + realmId: identity.realmId ?? null, + realmName: identity.realmName ?? null, + }, + }); + + return response.status === 'success'; + } + + protected buildKey(permission: string, identity: CallerIdentity): string { + return [permission, identity.type, identity.id, identity.realmId ?? '', identity.clientId ?? ''].join('|'); + } +} diff --git a/apps/node-message-broker/src/adapters/authz/http-permission-provider.ts b/apps/node-message-broker/src/adapters/authz/http-permission-provider.ts new file mode 100644 index 0000000..b4d8199 --- /dev/null +++ b/apps/node-message-broker/src/adapters/authz/http-permission-provider.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import type { IPermissionProvider, PermissionGetOptions, PermissionPolicyBinding } from '@authup/access'; +import type { CallerIdentity, IPermissionCheckGateway } from '../../core/authz/index.ts'; + +/** + * An `@authup/access` {@link IPermissionProvider} backed by an HTTP capability check. + * Built per request around the caller's identity; `findOne` delegates the decision to + * the Hub via the {@link IPermissionCheckGateway} and returns a **policy-less** binding + * (an unconditional grant once the engine evaluates it) on success, or `null` (deny). + * + * This plugs the engine's one extension seam so `useRequestPermissionChecker().check()` + * evaluates against Authup over HTTP instead of the token's (soon-removed) introspection + * permissions. + */ +export class HttpPermissionProvider implements IPermissionProvider { + protected gateway: IPermissionCheckGateway; + + protected identity: CallerIdentity; + + constructor(gateway: IPermissionCheckGateway, identity: CallerIdentity) { + this.gateway = gateway; + this.identity = identity; + } + + async findOne(criteria: PermissionGetOptions): Promise { + const granted = await this.gateway.holds(criteria.name, this.identity); + if (!granted) { + return null; + } + + return { permission: { name: criteria.name } }; + } +} diff --git a/apps/node-message-broker/src/adapters/authz/index.ts b/apps/node-message-broker/src/adapters/authz/index.ts new file mode 100644 index 0000000..2bdd964 --- /dev/null +++ b/apps/node-message-broker/src/adapters/authz/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +export * from './authup-permission-gateway.ts'; +export * from './http-permission-provider.ts'; diff --git a/apps/node-message-broker/src/adapters/http/middleware/permission-checker.ts b/apps/node-message-broker/src/adapters/http/middleware/permission-checker.ts new file mode 100644 index 0000000..298e20c --- /dev/null +++ b/apps/node-message-broker/src/adapters/http/middleware/permission-checker.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { PermissionEvaluator } from '@authup/access'; +import { + RequestPermissionChecker, + setRequestPermissionChecker, + useRequestIdentity, +} from '@privateaim/server-http-kit'; +import type { App } from 'routup'; +import { defineCoreHandler } from 'routup'; +import type { CallerIdentity, IPermissionCheckGateway } from '../../../core/authz/index.ts'; +import { HttpPermissionProvider } from '../../authz/index.ts'; + +/** + * Overrides the request permission checker so `useRequestPermissionChecker().check()` + * evaluates against Authup over HTTP (via {@link HttpPermissionProvider}) instead of the + * token's introspection permissions. Mount it **after** the authorization middleware + * (which sets the verified identity) and **before** the controllers. + */ +export function mountPermissionChecker(app: App, gateway: IPermissionCheckGateway): void { + app.use(defineCoreHandler((event) => { + const identity = useRequestIdentity(event); + if (identity) { + const caller: CallerIdentity = { + id: identity.id, + type: identity.type, + clientId: identity.type === 'client' ? identity.id : null, + realmId: identity.realmId, + realmName: identity.realmName, + }; + + const provider = new HttpPermissionProvider(gateway, caller); + const evaluator = new PermissionEvaluator({ + provider, + realmId: caller.realmId, + clientId: caller.clientId, + }); + + setRequestPermissionChecker(event, new RequestPermissionChecker(event, evaluator)); + } + + return event.next(); + })); +} diff --git a/apps/node-message-broker/src/app/factory.ts b/apps/node-message-broker/src/app/factory.ts index 835b316..bdf22ca 100644 --- a/apps/node-message-broker/src/app/factory.ts +++ b/apps/node-message-broker/src/app/factory.ts @@ -19,6 +19,8 @@ export function createApplication(): Application { }) .withComponents() .withCoreClient() + .withAuthupHook() + .withAuthupClient() .withHTTP(); return builder.build(); diff --git a/apps/node-message-broker/src/app/modules/core-client/constants.ts b/apps/node-message-broker/src/app/modules/core-client/constants.ts index cadb4be..6b2ce11 100644 --- a/apps/node-message-broker/src/app/modules/core-client/constants.ts +++ b/apps/node-message-broker/src/app/modules/core-client/constants.ts @@ -6,6 +6,9 @@ */ import { TypedToken } from 'eldin'; -import type { IParticipantResolver } from '../../../core/analysis/index.ts'; +import type { IAnalysisClientLookup, IParticipantResolver } from '../../../core/analysis/index.ts'; -export const CoreClientInjectionKey = { ParticipantResolver: new TypedToken('ParticipantResolver') }; +export const CoreClientInjectionKey = { + ParticipantResolver: new TypedToken('ParticipantResolver'), + AnalysisClientLookup: new TypedToken('AnalysisClientLookup'), +}; diff --git a/apps/node-message-broker/src/app/modules/core-client/module.ts b/apps/node-message-broker/src/app/modules/core-client/module.ts index ca0be42..8217fcb 100644 --- a/apps/node-message-broker/src/app/modules/core-client/module.ts +++ b/apps/node-message-broker/src/app/modules/core-client/module.ts @@ -13,7 +13,7 @@ import { createAuthupClientAuthenticationHook, createAuthupClientTokenCreator, } from '@privateaim/server-kit'; -import type { IAnalysisNodeProvider } from '../../../core/analysis/index.ts'; +import type { IAnalysisClientLookup, IAnalysisNodeProvider } from '../../../core/analysis/index.ts'; import { ParticipantResolver } from '../../../adapters/core/index.ts'; import { ConfigInjectionKey } from '../config/constants.ts'; import { CoreClientInjectionKey } from './constants.ts'; @@ -74,6 +74,16 @@ export class CoreClientModule implements IModule { logger, }); container.register(CoreClientInjectionKey.ParticipantResolver, { useValue: resolver }); + + // resolves the Authup client owning an analysis (server-core), used by the + // analysis policy (S3) to bind a caller's token to the path analysis. + const analysisClientLookup: IAnalysisClientLookup = { + getClientId: async (analysisId) => { + const analysis = await client.analysis.getOne(analysisId); + return analysis.client_id; + }, + }; + container.register(CoreClientInjectionKey.AnalysisClientLookup, { useValue: analysisClientLookup }); } async teardown(): Promise { diff --git a/apps/node-message-broker/src/app/modules/http/module.ts b/apps/node-message-broker/src/app/modules/http/module.ts index 5e5c20f..ff852bb 100644 --- a/apps/node-message-broker/src/app/modules/http/module.ts +++ b/apps/node-message-broker/src/app/modules/http/module.ts @@ -6,7 +6,7 @@ */ import type { IContainer } from 'eldin'; -import type { IModule } from 'orkos'; +import type { IModule, ModuleDependency } from 'orkos'; import { App, defineCoreHandler, serve } from 'routup'; import { AuthupClientInjectionKey, @@ -17,9 +17,12 @@ import { } from '@privateaim/server-kit'; import { createAuthupTokenVerifier, + mountDecoratorsMiddleware, mountErrorMiddleware, mountMiddlewares, } from '@privateaim/server-http-kit'; +import { AuthupPermissionGateway } from '../../../adapters/authz/index.ts'; +import { mountPermissionChecker } from '../../../adapters/http/middleware/permission-checker.ts'; import { ConfigInjectionKey } from '../config/constants.ts'; import { createControllers } from './controller.ts'; import type { HTTPServer } from './constants.ts'; @@ -28,7 +31,9 @@ import { HTTPInjectionKey } from './constants.ts'; export class HTTPModule implements IModule { readonly name = 'http'; - readonly dependencies: string[] = ['config', 'components']; + // `authupClient` is optional so partial builds (e.g. tests) still work; when present + // it must set up before HTTP so the authorization middleware + permission checker run. + readonly dependencies: (string | ModuleDependency)[] = ['config', 'components', { name: 'authupClient', optional: true }]; private instance: HTTPServer | undefined; @@ -48,6 +53,8 @@ export class HTTPModule implements IModule { const authupResult = container.tryResolve(AuthupClientInjectionKey); const redisResult = container.tryResolve(RedisClientInjectionKey); + // controllers are mounted separately (below) so the permission-checker override + // can sit between the authorization middleware and the controllers. mountMiddlewares(app, { basic: true, cors: true, @@ -69,9 +76,18 @@ export class HTTPModule implements IModule { }), }, swagger: false, - decorators: { controllers }, }); + // Override the request permission checker so capabilities evaluate against Authup + // over HTTP. Skipped in tests, where the authorization middleware's dry-run grant + // applies and no Authup client is reachable. + if (authupResult.success && !isTestEnvironment) { + const gateway = new AuthupPermissionGateway({ client: authupResult.data }); + mountPermissionChecker(app, gateway); + } + + mountDecoratorsMiddleware(app, { controllers }); + mountErrorMiddleware(app, { logger }); logger.debug('Starting http server...'); diff --git a/apps/node-message-broker/src/core/analysis/index.ts b/apps/node-message-broker/src/core/analysis/index.ts index 0b5c307..855f117 100644 --- a/apps/node-message-broker/src/core/analysis/index.ts +++ b/apps/node-message-broker/src/core/analysis/index.ts @@ -6,3 +6,4 @@ */ export * from './types.ts'; +export * from './policy.ts'; diff --git a/apps/node-message-broker/src/core/analysis/policy.ts b/apps/node-message-broker/src/core/analysis/policy.ts new file mode 100644 index 0000000..0cefd76 --- /dev/null +++ b/apps/node-message-broker/src/core/analysis/policy.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { ForbiddenError } from '@ebec/http'; +import type { IAnalysisClientLookup } from './types.ts'; + +/** + * Assert the caller's client owns `analysisId` — the node-specific analysis-scope rule + * (the Hub is analysis-agnostic). The caller's client comes from the verified request + * identity and must equal the analysis's client in server-core. Throws + * {@link ForbiddenError} on a missing client or a mismatch. + * + * Each analysis has a **dedicated** client (1:1), so matching the caller's client to the + * analysis owner is exact analysis-level isolation — a different analysis resolves to a + * different client, and a caller can only satisfy the check for its own analysis. + * + * The `ANALYSIS_SELF_MESSAGE_BROKER_USE` capability is enforced separately at the route + * via the request permission checker, so it is intentionally not handled here. + */ +export async function assertClientOwnsAnalysis( + analyses: IAnalysisClientLookup, + analysisId: string, + clientId: string | undefined, +): Promise { + if (!clientId) { + throw new ForbiddenError('The caller is not bound to a client.'); + } + + const analysisClientId = await analyses.getClientId(analysisId); + if (!analysisClientId || analysisClientId !== clientId) { + throw new ForbiddenError('The caller does not belong to this analysis.'); + } +} diff --git a/apps/node-message-broker/src/core/analysis/types.ts b/apps/node-message-broker/src/core/analysis/types.ts index 8f60c9c..5f1d75d 100644 --- a/apps/node-message-broker/src/core/analysis/types.ts +++ b/apps/node-message-broker/src/core/analysis/types.ts @@ -33,12 +33,12 @@ export interface IAnalysisNodeProvider { } /** - * Analysis authorization lives node-side (the Hub is analysis-agnostic). Asserts - * the calling analysis client holds `ANALYSIS_SELF_MESSAGE_BROKER_USE` — read from - * the analysis client's token claims, or via server-core introspection. + * Resolves the Authup client id that owns an analysis (server-core), used to bind + * a caller to its analysis. Narrow port; the live implementation wraps + * `@privateaim/core-http-kit`'s `analysis.getOne`. */ -export interface IAnalysisPolicy { - assertMayUse(analysisId: string, token: string): Promise; +export interface IAnalysisClientLookup { + getClientId(analysisId: string): Promise; } /** diff --git a/apps/node-message-broker/src/core/authz/index.ts b/apps/node-message-broker/src/core/authz/index.ts new file mode 100644 index 0000000..0b5c307 --- /dev/null +++ b/apps/node-message-broker/src/core/authz/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +export * from './types.ts'; diff --git a/apps/node-message-broker/src/core/authz/types.ts b/apps/node-message-broker/src/core/authz/types.ts new file mode 100644 index 0000000..50e4a8d --- /dev/null +++ b/apps/node-message-broker/src/core/authz/types.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +/** + * The caller subset a permission check needs, taken from the verified request identity + * (`useRequestIdentity`). Mirrors Authup's `IdentityPolicyData` so it can ride in the + * `/permissions/:id/check` body. + */ +export type CallerIdentity = { + id: string, + /** `user` | `client` | `robot` */ + type: string, + clientId?: string | null, + realmId?: string | null, + realmName?: string | null +}; + +/** + * Asks Authup whether a caller holds a named permission. Narrow port so the HTTP + * permission provider is testable with a fake; the live implementation wraps the Authup + * client's `permission.check` (`POST /permissions/:name/check`, caller identity in the + * body) and caches the answer for a few seconds. + */ +export interface IPermissionCheckGateway { + holds(permission: string, identity: CallerIdentity): Promise; +} diff --git a/apps/node-message-broker/test/unit/adapters/authz/authup-permission-gateway.spec.ts b/apps/node-message-broker/test/unit/adapters/authz/authup-permission-gateway.spec.ts new file mode 100644 index 0000000..e0e4d80 --- /dev/null +++ b/apps/node-message-broker/test/unit/adapters/authz/authup-permission-gateway.spec.ts @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { describe, expect, it } from 'vitest'; +import type { CallerIdentity } from '../../../../src/core/authz/index.ts'; +import { AuthupPermissionGateway } from '../../../../src/adapters/authz/index.ts'; +import { FakePermissionCheckClient } from './fake-permission-check-client.ts'; + +const IDENTITY: CallerIdentity = { + id: 'client-analysis', + type: 'client', + clientId: 'client-analysis', + realmId: 'realm-1', + realmName: 'analysis', +}; + +describe('adapters/authz/authup-permission-gateway', () => { + it('grants on status "success" and passes the caller identity in the body', async () => { + const client = new FakePermissionCheckClient(); + const gateway = new AuthupPermissionGateway({ client }); + + await expect(gateway.holds('analysis_self_message_broker_use', IDENTITY)).resolves.toBe(true); + expect(client.calls).toHaveLength(1); + expect(client.calls[0].idOrName).toBe('analysis_self_message_broker_use'); + expect(client.calls[0].data).toEqual({ + identity: { + type: 'client', + id: 'client-analysis', + clientId: 'client-analysis', + realmId: 'realm-1', + realmName: 'analysis', + }, + }); + }); + + it('denies on any non-success status', async () => { + const client = new FakePermissionCheckClient(); + client.status = 'error'; + const gateway = new AuthupPermissionGateway({ client }); + + await expect(gateway.holds('analysis_self_message_broker_use', IDENTITY)).resolves.toBe(false); + }); + + it('caches the decision (a repeat check within TTL does not re-hit the client)', async () => { + const client = new FakePermissionCheckClient(); + const gateway = new AuthupPermissionGateway({ client }); + + await gateway.holds('p', IDENTITY); + await gateway.holds('p', IDENTITY); + + expect(client.calls).toHaveLength(1); + }); + + it('keys the cache by permission + identity', async () => { + const client = new FakePermissionCheckClient(); + const gateway = new AuthupPermissionGateway({ client }); + + await gateway.holds('p', IDENTITY); + await gateway.holds('q', IDENTITY); + await gateway.holds('p', { + ...IDENTITY, + id: 'other', + clientId: 'other', + }); + + expect(client.calls).toHaveLength(3); + }); + + it('does not cache a transient failure (retries on the next check)', async () => { + const client = new FakePermissionCheckClient(); + client.error = new Error('hub unreachable'); + const gateway = new AuthupPermissionGateway({ client }); + + await expect(gateway.holds('p', IDENTITY)).rejects.toThrow(/hub unreachable/); + + client.error = undefined; + await expect(gateway.holds('p', IDENTITY)).resolves.toBe(true); + expect(client.calls).toHaveLength(2); + }); + + it('with caching disabled (ttl <= 0) hits the client every time', async () => { + const client = new FakePermissionCheckClient(); + const gateway = new AuthupPermissionGateway({ client, cacheTtlMs: 0 }); + + await gateway.holds('p', IDENTITY); + await gateway.holds('p', IDENTITY); + + expect(client.calls).toHaveLength(2); + }); +}); diff --git a/apps/node-message-broker/test/unit/adapters/authz/fake-permission-check-client.ts b/apps/node-message-broker/test/unit/adapters/authz/fake-permission-check-client.ts new file mode 100644 index 0000000..e9bdbe2 --- /dev/null +++ b/apps/node-message-broker/test/unit/adapters/authz/fake-permission-check-client.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +type CheckCall = { idOrName: string, data?: Record }; + +/** + * In-memory stand-in for the Authup client's `permission.check` — records every call + * and returns a configurable status (or throws), so the gateway is testable without a + * live Authup server. Structurally satisfies the gateway's `PermissionCheckClient`. + */ +export class FakePermissionCheckClient { + calls: CheckCall[] = []; + + status: 'success' | 'error' = 'success'; + + /** When set, the next `check` rejects with this error. */ + error: Error | undefined; + + permission = { + check: async (idOrName: string, data?: Record): Promise<{ status: string }> => { + this.calls.push({ idOrName, data }); + if (this.error) { + throw this.error; + } + return { status: this.status }; + }, + }; +} diff --git a/apps/node-message-broker/test/unit/adapters/authz/fake-permission-gateway.ts b/apps/node-message-broker/test/unit/adapters/authz/fake-permission-gateway.ts new file mode 100644 index 0000000..ba13f0e --- /dev/null +++ b/apps/node-message-broker/test/unit/adapters/authz/fake-permission-gateway.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import type { CallerIdentity, IPermissionCheckGateway } from '../../../../src/core/authz/index.ts'; + +/** + * In-memory `IPermissionCheckGateway` that records every lookup and returns a + * configurable grant decision (or throws) — stands in for the HTTP capability check so + * the permission provider is testable without a live Authup server. + */ +export class FakePermissionCheckGateway implements IPermissionCheckGateway { + calls: { permission: string, identity: CallerIdentity }[] = []; + + result = true; + + /** When set, the next `holds` rejects with this error. */ + error: Error | undefined; + + holds = async (permission: string, identity: CallerIdentity): Promise => { + this.calls.push({ permission, identity }); + if (this.error) { + throw this.error; + } + return this.result; + }; +} diff --git a/apps/node-message-broker/test/unit/adapters/authz/http-permission-provider.spec.ts b/apps/node-message-broker/test/unit/adapters/authz/http-permission-provider.spec.ts new file mode 100644 index 0000000..bb0fbe2 --- /dev/null +++ b/apps/node-message-broker/test/unit/adapters/authz/http-permission-provider.spec.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { describe, expect, it } from 'vitest'; +import type { CallerIdentity } from '../../../../src/core/authz/index.ts'; +import { HttpPermissionProvider } from '../../../../src/adapters/authz/index.ts'; +import { FakePermissionCheckGateway } from './fake-permission-gateway.ts'; + +const IDENTITY: CallerIdentity = { + id: 'client-analysis', + type: 'client', + clientId: 'client-analysis', + realmId: 'realm-1', +}; + +describe('adapters/authz/http-permission-provider', () => { + it('returns a policy-less binding (grant) when the gateway grants', async () => { + const gateway = new FakePermissionCheckGateway(); + const provider = new HttpPermissionProvider(gateway, IDENTITY); + + const binding = await provider.findOne({ name: 'analysis_self_message_broker_use' }); + + expect(binding).toEqual({ permission: { name: 'analysis_self_message_broker_use' } }); + expect(gateway.calls).toEqual([{ permission: 'analysis_self_message_broker_use', identity: IDENTITY }]); + }); + + it('returns null (deny) when the gateway denies', async () => { + const gateway = new FakePermissionCheckGateway(); + gateway.result = false; + const provider = new HttpPermissionProvider(gateway, IDENTITY); + + const binding = await provider.findOne({ name: 'analysis_self_message_broker_use' }); + + expect(binding).toBeNull(); + }); + + it('propagates a gateway failure rather than denying', async () => { + const gateway = new FakePermissionCheckGateway(); + gateway.error = new Error('hub unreachable'); + const provider = new HttpPermissionProvider(gateway, IDENTITY); + + await expect(provider.findOne({ name: 'analysis_self_message_broker_use' })).rejects.toThrow(/hub unreachable/); + }); +}); diff --git a/apps/node-message-broker/test/unit/core/analysis/fake-analysis-client-lookup.ts b/apps/node-message-broker/test/unit/core/analysis/fake-analysis-client-lookup.ts new file mode 100644 index 0000000..8d4b160 --- /dev/null +++ b/apps/node-message-broker/test/unit/core/analysis/fake-analysis-client-lookup.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import type { IAnalysisClientLookup } from '../../../../src/core/analysis/index.ts'; + +/** + * In-memory `IAnalysisClientLookup` that records every lookup and resolves the owning + * client id from a configurable map — stands in for server-core's analysis → client + * lookup so the analysis policy is testable without a live core client. Unknown + * analyses resolve to `null`. + */ +export class FakeAnalysisClientLookup implements IAnalysisClientLookup { + calls: string[] = []; + + clientIdByAnalysis = new Map([['a1', 'client-analysis']]); + + getClientId = async (analysisId: string): Promise => { + this.calls.push(analysisId); + return this.clientIdByAnalysis.get(analysisId) ?? null; + }; +} diff --git a/apps/node-message-broker/test/unit/core/analysis/policy.spec.ts b/apps/node-message-broker/test/unit/core/analysis/policy.spec.ts new file mode 100644 index 0000000..0af1d0d --- /dev/null +++ b/apps/node-message-broker/test/unit/core/analysis/policy.spec.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { ForbiddenError } from '@ebec/http'; +import { describe, expect, it } from 'vitest'; +import { assertClientOwnsAnalysis } from '../../../../src/core/analysis/index.ts'; +import { FakeAnalysisClientLookup } from './fake-analysis-client-lookup.ts'; + +describe('core/analysis/policy', () => { + it('allows a caller whose client owns the analysis', async () => { + const analyses = new FakeAnalysisClientLookup(); + + await expect(assertClientOwnsAnalysis(analyses, 'a1', 'client-analysis')).resolves.toBeUndefined(); + expect(analyses.calls).toEqual(['a1']); + }); + + it('rejects a caller not bound to a client with a ForbiddenError', async () => { + const analyses = new FakeAnalysisClientLookup(); + + const error = await assertClientOwnsAnalysis(analyses, 'a1', undefined).catch((err) => err); + expect(error).toBeInstanceOf(ForbiddenError); + expect(error.message).toMatch(/not bound to a client/i); + // the analysis is never looked up once the caller has no client + expect(analyses.calls).toEqual([]); + }); + + it('rejects an unknown analysis (no owner client) with a ForbiddenError', async () => { + const analyses = new FakeAnalysisClientLookup(); + + const error = await assertClientOwnsAnalysis(analyses, 'unknown', 'client-analysis').catch((err) => err); + expect(error).toBeInstanceOf(ForbiddenError); + expect(error.message).toMatch(/does not belong to this analysis/i); + }); + + it('rejects when the caller client does not own the analysis with a ForbiddenError', async () => { + const analyses = new FakeAnalysisClientLookup(); + analyses.clientIdByAnalysis.set('a1', 'client-other'); + + const error = await assertClientOwnsAnalysis(analyses, 'a1', 'client-analysis').catch((err) => err); + expect(error).toBeInstanceOf(ForbiddenError); + expect(error.message).toMatch(/does not belong to this analysis/i); + }); +});