-
Notifications
You must be signed in to change notification settings - Fork 0
feat(node-message-broker): analysis authorization — scope binding + capability check #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
94 changes: 94 additions & 0 deletions
94
apps/node-message-broker/src/adapters/authz/authup-permission-gateway.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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('|'); | ||
| } | ||
| } |
39 changes: 39 additions & 0 deletions
39
apps/node-message-broker/src/adapters/authz/http-permission-provider.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } }; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; |
49 changes: 49 additions & 0 deletions
49
apps/node-message-broker/src/adapters/http/middleware/permission-checker.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| })); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,3 +6,4 @@ | |
| */ | ||
|
|
||
| export * from './types.ts'; | ||
| export * from './policy.ts'; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.'); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
Repository: PrivateAIM/node
Length of output: 2575
🏁 Script executed:
Repository: PrivateAIM/node
Length of output: 1613
🏁 Script executed:
Repository: PrivateAIM/node
Length of output: 153
🏁 Script executed:
Repository: PrivateAIM/node
Length of output: 2448
🏁 Script executed:
Repository: PrivateAIM/node
Length of output: 153
🏁 Script executed:
Repository: PrivateAIM/node
Length of output: 319
🏁 Script executed:
Repository: PrivateAIM/node
Length of output: 1381
🏁 Script executed:
Repository: PrivateAIM/node
Length of output: 153
Remove the
isTestEnvironmentgate for theAuthupPermissionGatewaymount to enforce strict authorization in production.If
authupClientfails 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 theAuthupPermissionGatewayadapter. The authorization configuration passed tomountMiddlewaresaccepts anundefinedclient, 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 checkelse throw) to ensureAuthupPermissionGatewayis always mounted when a valid client is resolved, or fail the startup process if the client resolution logic is unreliable.🤖 Prompt for AI Agents