import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; import { ConnectorLeaseCoordinator, normalizeConnectorLease } from '@mosaicstack/agent'; import { normalizeConnectorId, normalizeConnectorScopes, normalizeCorrelationId, normalizeLogicalAgentIdentity, normalizeLogicalBindingId, type AcquireConnectorLeaseInput, type ConnectorExecutionGrant, type ConnectorLease, type ConnectorLeaseAuditEvent, type FencedConnectorAdapter, } from '@mosaicstack/types'; import type { ActorTenantScope } from '../auth/session-scope.js'; import { ConnectorLeaseRepository } from './connector-lease.repository.js'; export const CONNECTOR_LEASE_POLICY = Symbol('CONNECTOR_LEASE_POLICY'); export type ConnectorLeasePolicyAction = | 'lease.acquire' | 'lease.takeover' | 'lease.heartbeat' | 'lease.release' | 'lease.read' | 'grant.issue'; export interface ConnectorLeaseRequestContext { readonly actorScope: ActorTenantScope; readonly correlationId: string; } export interface GatewayConnectorLeaseRequest { readonly logicalAgentId: string; readonly bindingId: string; readonly connectorId: string; readonly scopes: readonly string[]; readonly ttlMs: number; } export interface GatewayConnectorLeaseTakeoverRequest extends GatewayConnectorLeaseRequest { readonly expectedEpoch: string; } export interface GatewayConnectorGrantRequest { readonly lease: ConnectorLease; readonly scopes: readonly string[]; readonly ttlMs: number; } export interface ConnectorLeasePolicySubject { readonly action: ConnectorLeasePolicyAction; readonly actorId: string; readonly tenantId: string; readonly logicalAgentId: string; readonly bindingId: string; readonly connectorId: string; readonly requestedScopes: readonly string[]; readonly requestedTtlMs: number | null; } export interface ConnectorLeasePolicy { authorize(subject: ConnectorLeasePolicySubject): Promise; } /** M1 has no concrete cutover policy: unconfigured production use fails closed. */ @Injectable() export class DenyConnectorLeasePolicy implements ConnectorLeasePolicy { async authorize(_subject: ConnectorLeasePolicySubject): Promise { return false; } } /** Gateway-owned policy surface for durable connector authority and fenced effects. */ @Injectable() export class ConnectorLeaseService { private readonly coordinator: ConnectorLeaseCoordinator; constructor( @Inject(ConnectorLeaseRepository) private readonly repository: ConnectorLeaseRepository, @Inject(CONNECTOR_LEASE_POLICY) private readonly policy: ConnectorLeasePolicy, ) { this.coordinator = new ConnectorLeaseCoordinator(repository); } async acquire( request: GatewayConnectorLeaseRequest, context: ConnectorLeaseRequestContext, ): Promise { const command = this.command(request, context); await this.assertPolicy('lease.acquire', command, context, command.scopes, command.ttlMs); return this.coordinator.acquire({ ...command, correlationId: this.correlation(context) }); } async takeover( request: GatewayConnectorLeaseTakeoverRequest, context: ConnectorLeaseRequestContext, ): Promise { const command = this.command(request, context); await this.assertPolicy('lease.takeover', command, context, command.scopes, command.ttlMs); return this.coordinator.takeover({ ...command, expectedEpoch: request.expectedEpoch, correlationId: this.correlation(context), }); } async heartbeat( lease: ConnectorLease, ttlMs: number, context: ConnectorLeaseRequestContext, ): Promise { const normalizedLease = normalizeConnectorLease(lease); const durableLease = await this.durableLifecycleLease(normalizedLease, context); await this.assertPolicy('lease.heartbeat', durableLease, context, durableLease.scopes, ttlMs); return this.coordinator.heartbeat({ lease: durableLease, ttlMs, correlationId: this.correlation(context), }); } async release(lease: ConnectorLease, context: ConnectorLeaseRequestContext): Promise { const normalizedLease = normalizeConnectorLease(lease); const durableLease = await this.durableLifecycleLease(normalizedLease, context); await this.assertPolicy('lease.release', durableLease, context, durableLease.scopes, null); await this.coordinator.release({ lease: durableLease, correlationId: this.correlation(context), }); } async current( logicalAgentId: string, bindingId: string, context: ConnectorLeaseRequestContext, ): Promise { const binding = { identity: normalizeLogicalAgentIdentity({ tenantId: context.actorScope.tenantId, logicalAgentId, }), bindingId: normalizeLogicalBindingId(bindingId), connectorId: 'gateway', }; await this.assertPolicy('lease.read', binding, context, [], null); return this.coordinator.current(binding); } async issueGrant( request: GatewayConnectorGrantRequest, context: ConnectorLeaseRequestContext, ): Promise { const lease = normalizeConnectorLease(request.lease); await this.assertTenant(lease, context); const scopes = normalizeConnectorScopes(request.scopes); await this.assertPolicy('grant.issue', lease, context, scopes, request.ttlMs); return this.coordinator.issueGrant({ lease, scopes, ttlMs: request.ttlMs, correlationId: this.correlation(context), }); } async executeGrant( grant: ConnectorExecutionGrant, requiredScope: string, input: TInput, adapter: FencedConnectorAdapter, ): Promise { return this.coordinator.executeGrant(grant, requiredScope, input, adapter); } private command( request: GatewayConnectorLeaseRequest, context: ConnectorLeaseRequestContext, ): Omit { return { identity: normalizeLogicalAgentIdentity({ tenantId: context.actorScope.tenantId, logicalAgentId: request.logicalAgentId, }), bindingId: normalizeLogicalBindingId(request.bindingId), connectorId: normalizeConnectorId(request.connectorId), scopes: normalizeConnectorScopes(request.scopes), ttlMs: request.ttlMs, }; } private async assertTenant( lease: Pick, context: ConnectorLeaseRequestContext, ): Promise { if (lease.identity.tenantId !== context.actorScope.tenantId) { await this.recordPolicyDenial( { identity: { tenantId: context.actorScope.tenantId, logicalAgentId: 'untrusted', }, bindingId: 'untrusted', connectorId: 'untrusted', }, context, ); throw new ForbiddenException('Connector authority tenant scope denied'); } } private async durableLifecycleLease( submittedLease: ConnectorLease, context: ConnectorLeaseRequestContext, ): Promise { await this.assertTenant(submittedLease, context); const durableLease = await this.coordinator.current(submittedLease); if (!durableLease || !hasSameLifecycleAuthority(submittedLease, durableLease)) { await this.recordPolicyDenial(durableLease ?? submittedLease, context); throw new ForbiddenException('Connector authority policy denied'); } return durableLease; } private async assertPolicy( action: ConnectorLeasePolicyAction, subject: Pick, context: ConnectorLeaseRequestContext, requestedScopes: readonly string[], requestedTtlMs: number | null, ): Promise { const allowed = await this.policy.authorize({ action, actorId: context.actorScope.userId, tenantId: subject.identity.tenantId, logicalAgentId: subject.identity.logicalAgentId, bindingId: subject.bindingId, connectorId: subject.connectorId, requestedScopes: Object.freeze([...requestedScopes]), requestedTtlMs, }); if (!allowed) { await this.recordPolicyDenial(subject, context); throw new ForbiddenException('Connector authority policy denied'); } } private async recordPolicyDenial( subject: Pick, context: ConnectorLeaseRequestContext, ): Promise { const event: ConnectorLeaseAuditEvent = { identity: subject.identity, bindingId: subject.bindingId, connectorId: subject.connectorId, event: 'reject', outcome: 'denied', reason: 'policy_denied', correlationId: this.correlation(context), occurredAt: new Date().toISOString(), }; await this.repository.recordAudit(event); } private correlation(context: ConnectorLeaseRequestContext): string { return normalizeCorrelationId(context.correlationId); } } function hasSameLifecycleAuthority( submittedLease: ConnectorLease, durableLease: ConnectorLease, ): boolean { return ( submittedLease.identity.tenantId === durableLease.identity.tenantId && submittedLease.identity.logicalAgentId === durableLease.identity.logicalAgentId && submittedLease.bindingId === durableLease.bindingId && submittedLease.leaseId === durableLease.leaseId && submittedLease.connectorId === durableLease.connectorId && submittedLease.leaseEpoch === durableLease.leaseEpoch && submittedLease.scopes.length === durableLease.scopes.length && submittedLease.scopes.every((scope: string, index: number): boolean => { return scope === durableLease.scopes[index]; }) ); }