3298401772
The socket used to accept any valid token, so a full REST/actor token could open the live stream. Now it can require a narrowly-scoped delegated token (aud=iios-message), so a leaked socket token can't drive privileged REST, and vice-versa. - MessagePrincipal gains `audience`, surfaced from both verify paths (OIDC aud, and the app-token `aud` claim). - message.gateway: when IIOS_REALTIME_AUDIENCE is set, handleConnection accepts only a token whose aud matches (opt-in, like IIOS_REQUIRE_ATTESTATION; unset = no change). - spec: verifier surfaces aud; gateway accepts iios-message, rejects iios-core / no-aud when enforcing, passes through when off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
82 lines
3.1 KiB
TypeScript
82 lines
3.1 KiB
TypeScript
import { ForbiddenException, Injectable } from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
/** The authenticated principal derived from the session port (token). */
|
|
export interface MessagePrincipal {
|
|
userId: string;
|
|
orgId: string;
|
|
appId: string;
|
|
tenantId?: string;
|
|
displayName?: string;
|
|
/** The token's audience (`aud`). Used to enforce realtime delegation: a socket-scoped
|
|
* token (`iios-message`) must not be a full REST/actor token (`iios-core`), and vice-versa. */
|
|
audience?: string;
|
|
}
|
|
|
|
/**
|
|
* Resolves a principal to kernel rows (scope → source handle → actor) and
|
|
* manages thread participation. Shared by the message + inbox layers so neither
|
|
* re-implements identity resolution. Unknown handles stay UNVERIFIED (the MDM
|
|
* port resolution is applied at ingest; native users get a PORTAL_USER handle).
|
|
*/
|
|
@Injectable()
|
|
export class ActorResolver {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async resolveScope(principal: MessagePrincipal) {
|
|
return (
|
|
(await this.findScope(principal)) ??
|
|
(await this.prisma.iiosScope.create({
|
|
data: {
|
|
orgId: principal.orgId,
|
|
appId: principal.appId,
|
|
tenantId: principal.tenantId,
|
|
// Cell-partition hook (P9): assign the tenant scope to a cell.
|
|
cellId: process.env.IIOS_CELL_ID ?? 'cell-default',
|
|
},
|
|
}))
|
|
);
|
|
}
|
|
|
|
/** Resolve the caller's scope WITHOUT creating it — a caller with no scope owns nothing. */
|
|
async findScope(principal: MessagePrincipal) {
|
|
return this.prisma.iiosScope.findFirst({
|
|
where: { orgId: principal.orgId, appId: principal.appId, tenantId: principal.tenantId ?? null },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Tenant fence (P9 / KG-02): throw unless the caller's scope owns the resource.
|
|
* Every by-id operation calls this before reading or mutating.
|
|
*/
|
|
async assertOwns(principal: MessagePrincipal, resourceScopeId: string) {
|
|
const scope = await this.findScope(principal);
|
|
if (!scope || scope.id !== resourceScopeId) {
|
|
throw new ForbiddenException('resource not in your tenant scope');
|
|
}
|
|
return scope;
|
|
}
|
|
|
|
async resolveActor(scopeId: string, principal: MessagePrincipal) {
|
|
const handle = await this.prisma.iiosSourceHandle.upsert({
|
|
where: { scopeId_kind_externalId: { scopeId, kind: 'PORTAL_USER', externalId: principal.userId } },
|
|
create: { scopeId, kind: 'PORTAL_USER', externalId: principal.userId, displayName: principal.displayName },
|
|
update: { lastSeenAt: new Date(), displayName: principal.displayName },
|
|
});
|
|
return (
|
|
(await this.prisma.iiosActorRef.findFirst({ where: { sourceHandleId: handle.id } })) ??
|
|
(await this.prisma.iiosActorRef.create({
|
|
data: { kind: 'HUMAN', sourceHandleId: handle.id, displayName: principal.displayName },
|
|
}))
|
|
);
|
|
}
|
|
|
|
async ensureParticipant(threadId: string, actorId: string, participantRole = 'MEMBER'): Promise<void> {
|
|
await this.prisma.iiosThreadParticipant.upsert({
|
|
where: { threadId_actorId: { threadId, actorId } },
|
|
create: { threadId, actorId, participantRole },
|
|
update: {},
|
|
});
|
|
}
|
|
}
|