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
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<string, Promise<boolean>> | 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<string, Promise<boolean>>({ max, ttl }) : undefined;
}

holds(permission: string, identity: CallerIdentity): Promise<boolean> {
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<boolean> {
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('|');
}
}
Original file line number Diff line number Diff line change
@@ -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<PermissionPolicyBinding | null> {
const granted = await this.gateway.holds(criteria.name, this.identity);
if (!granted) {
return null;
}

return { permission: { name: criteria.name } };
}
}
9 changes: 9 additions & 0 deletions apps/node-message-broker/src/adapters/authz/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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();
}));
}
2 changes: 2 additions & 0 deletions apps/node-message-broker/src/app/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export function createApplication(): Application {
})
.withComponents()
.withCoreClient()
.withAuthupHook()
.withAuthupClient()
.withHTTP();

return builder.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IParticipantResolver>('ParticipantResolver') };
export const CoreClientInjectionKey = {
ParticipantResolver: new TypedToken<IParticipantResolver>('ParticipantResolver'),
AnalysisClientLookup: new TypedToken<IAnalysisClientLookup>('AnalysisClientLookup'),
};
12 changes: 11 additions & 1 deletion apps/node-message-broker/src/app/modules/core-client/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
Expand Down
22 changes: 19 additions & 3 deletions apps/node-message-broker/src/app/modules/http/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -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;

Expand All @@ -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,
Expand All @@ -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);
}
Comment on lines +84 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how withAuthupClient registers AuthupClientInjectionKey and whether it can fail silently.
rg -nP -C4 'withAuthupClient|AuthupClientInjectionKey' apps/node-message-broker/src

Repository: PrivateAIM/node

Length of output: 2575


🏁 Script executed:

#!/bin/bash
# Check how mountMiddlewares is called and if authorization is always included.
sed -n '40,70p' apps/node-message-broker/src/app/modules/http/module.ts

Repository: PrivateAIM/node

Length of output: 1613


🏁 Script executed:

#!/bin/bash
# Find the mountMiddlewares function implementation to see how it handles undefined authupClient.
rg -n "function mountMiddlewares|^export.*mountMiddlewares" apps/node-message-broker/src --type ts

Repository: PrivateAIM/node

Length of output: 153


🏁 Script executed:

#!/bin/bash
# Search for mountMiddlewares definition (it might be named differently or in a different file)
rg -n "mountMiddlewares" apps/node-message-broker/src --type ts -A 2 --context 5

Repository: PrivateAIM/node

Length of output: 2448


🏁 Script executed:

#!/bin/bash
# Search in the node_modules or the privateaim package to find the implementation of mountMiddlewares.
# Since it's imported from `@privateaim/server-http-kit`, check if that local package exists in the repo.
fd "server-http-kit" --type d || fd "server-http" --type d

Repository: PrivateAIM/node

Length of output: 153


🏁 Script executed:

#!/bin/bash
# Look for tsconfig paths or check root for the internal packages
ls -la packages/ 2>/dev/null || ls -la apps/

Repository: PrivateAIM/node

Length of output: 319


🏁 Script executed:

#!/bin/bash
# Try to find where the internal libs are located (maybe in root)
find . -maxdepth 2 -type d -name "*server*" 2>/dev/null | head -10
# Also search for the token verifier to understand the "fallback" logic
rg "createAuthupTokenVerifier" apps/node-message-broker/src --type ts -A 5

Repository: PrivateAIM/node

Length of output: 1381


🏁 Script executed:

#!/bin/bash
# Search for files containing "mountMiddlewares" to see if there's a local implementation or if we can infer behavior.
# Also check imports from '`@privateaim/server-kit`' to see what's available.
rg "export.*mountMiddlewares" --type ts

Repository: PrivateAIM/node

Length of output: 153


Remove the isTestEnvironment gate for the AuthupPermissionGateway mount to enforce strict authorization in production.

If authupClient fails to resolve in a production environment, the current logic skips mounting the new permission checker, leaving the application reliant solely on the basic token verifier. This bypasses the required permission gateway logic defined by the AuthupPermissionGateway adapter. The authorization configuration passed to mountMiddlewares accepts an undefined client, which likely defaults to a legacy mode or fails to enrich permissions, violating the principle that the new permission system must be mandatory outside of test environments. Remove the conditional check && !isTestEnvironment (or check else throw) to ensure AuthupPermissionGateway is always mounted when a valid client is resolved, or fail the startup process if the client resolution logic is unreliable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/node-message-broker/src/app/modules/http/module.ts` around lines 84 -
87, Remove the test-environment bypass in module.ts so the
AuthupPermissionGateway is always mounted whenever authupResult.success is true,
using mountPermissionChecker and the AuthupPermissionGateway adapter without the
isTestEnvironment guard. If client resolution is not reliable in production,
fail startup explicitly instead of silently skipping permission gateway setup,
so the authorization path is consistently enforced outside tests.


mountDecoratorsMiddleware(app, { controllers });

mountErrorMiddleware(app, { logger });

logger.debug('Starting http server...');
Expand Down
1 change: 1 addition & 0 deletions apps/node-message-broker/src/core/analysis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
*/

export * from './types.ts';
export * from './policy.ts';
37 changes: 37 additions & 0 deletions apps/node-message-broker/src/core/analysis/policy.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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.');
}
}
10 changes: 5 additions & 5 deletions apps/node-message-broker/src/core/analysis/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
export interface IAnalysisClientLookup {
getClientId(analysisId: string): Promise<string | null>;
}

/**
Expand Down
8 changes: 8 additions & 0 deletions apps/node-message-broker/src/core/authz/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading