286 lines
9.6 KiB
TypeScript
286 lines
9.6 KiB
TypeScript
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<boolean>;
|
|
}
|
|
|
|
/** M1 has no concrete cutover policy: unconfigured production use fails closed. */
|
|
@Injectable()
|
|
export class DenyConnectorLeasePolicy implements ConnectorLeasePolicy {
|
|
async authorize(_subject: ConnectorLeasePolicySubject): Promise<boolean> {
|
|
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<ConnectorLease> {
|
|
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<ConnectorLease> {
|
|
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<ConnectorLease> {
|
|
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<void> {
|
|
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<ConnectorLease | null> {
|
|
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<ConnectorExecutionGrant> {
|
|
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<TInput, TOutput>(
|
|
grant: ConnectorExecutionGrant,
|
|
requiredScope: string,
|
|
input: TInput,
|
|
adapter: FencedConnectorAdapter<TInput, TOutput>,
|
|
): Promise<TOutput> {
|
|
return this.coordinator.executeGrant(grant, requiredScope, input, adapter);
|
|
}
|
|
|
|
private command(
|
|
request: GatewayConnectorLeaseRequest,
|
|
context: ConnectorLeaseRequestContext,
|
|
): Omit<AcquireConnectorLeaseInput, 'correlationId'> {
|
|
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<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
|
context: ConnectorLeaseRequestContext,
|
|
): Promise<void> {
|
|
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<ConnectorLease> {
|
|
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<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
|
context: ConnectorLeaseRequestContext,
|
|
requestedScopes: readonly string[],
|
|
requestedTtlMs: number | null,
|
|
): Promise<void> {
|
|
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<ConnectorLease, 'identity' | 'bindingId' | 'connectorId'>,
|
|
context: ConnectorLeaseRequestContext,
|
|
): Promise<void> {
|
|
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];
|
|
})
|
|
);
|
|
}
|