add office-aware passport policies and alert lifecycle

Enforce geofenced location evidence for passport challenges and extend admin alerting so mobile devices can review, dismiss, and act on real org and security events.
This commit is contained in:
2026-04-20 13:21:28 +00:00
parent a1a684ee81
commit e9eb9b4172
11 changed files with 548 additions and 37 deletions
+146 -3
View File
@@ -137,6 +137,47 @@ export class PassportManager {
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportDashboard>(
'getPassportDashboard',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'getPassportDashboard',
});
const user = await this.receptionRef.userManager.CUser.getInstance({
id: passportDevice.data.userId,
});
const organizations = user
? await this.receptionRef.organizationmanager.getAllOrganizationsForUser(user)
: [];
const devices = await this.getPassportDevicesForUser(passportDevice.data.userId);
const challenges = await this.listPendingChallengesForDevice(passportDevice.id);
const alerts = await this.receptionRef.alertManager.listAlertsForUser(passportDevice.data.userId);
return {
profile: {
userId: passportDevice.data.userId,
name: user?.data?.name || user?.data?.email || 'Passport User',
handle: user?.data?.username || user?.data?.email || passportDevice.data.userId,
organizations: organizations.map((organizationArg) => ({
id: organizationArg.id,
name: organizationArg.data.name,
})),
deviceCount: devices.length,
recoverySummary: 'Recovery workflows are not configured yet for this passport.',
},
devices: devices.map((deviceArg) => ({ id: deviceArg.id, data: deviceArg.data })),
challenges: challenges.map((challengeArg) => ({
challenge: { id: challengeArg.id, data: challengeArg.data },
signingPayload: this.buildChallengeSigningPayload(challengeArg),
})),
alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })),
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ApprovePassportChallenge>(
'approvePassportChallenge',
@@ -161,6 +202,26 @@ export class PassportManager {
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RejectPassportChallenge>(
'rejectPassportChallenge',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'rejectPassportChallenge',
signedFields: [`challenge_id=${requestArg.challengeId}`],
});
const challenge = await this.rejectPassportChallenge(passportDevice.id, requestArg.challengeId);
return {
success: true,
challenge: {
id: challenge.id,
data: challenge.data,
},
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegisterPassportPushToken>(
'registerPassportPushToken',
@@ -225,8 +286,11 @@ export class PassportManager {
return {
challenge: passportChallenge
? {
id: passportChallenge.id,
data: passportChallenge.data,
challenge: {
id: passportChallenge.id,
data: passportChallenge.data,
},
signingPayload: this.buildChallengeSigningPayload(passportChallenge),
}
: undefined,
};
@@ -374,9 +438,43 @@ export class PassportManager {
`audience=${challengeArg.data.metadata.audience || ''}`,
`require_location=${challengeArg.data.metadata.requireLocation}`,
`require_nfc=${challengeArg.data.metadata.requireNfc}`,
`location_policy=${challengeArg.data.metadata.locationPolicy ? JSON.stringify(challengeArg.data.metadata.locationPolicy) : ''}`,
].join('\n');
}
private evaluateLocationPolicy(
locationPolicyArg: plugins.idpInterfaces.data.IPassportLocationPolicy,
locationEvidenceArg: plugins.idpInterfaces.data.IPassportLocationEvidence
) {
const earthRadiusMeters = 6371000;
const latitude1 = (locationPolicyArg.latitude * Math.PI) / 180;
const latitude2 = (locationEvidenceArg.latitude * Math.PI) / 180;
const deltaLatitude = ((locationEvidenceArg.latitude - locationPolicyArg.latitude) * Math.PI) / 180;
const deltaLongitude = ((locationEvidenceArg.longitude - locationPolicyArg.longitude) * Math.PI) / 180;
const haversine =
Math.sin(deltaLatitude / 2) * Math.sin(deltaLatitude / 2) +
Math.cos(latitude1) * Math.cos(latitude2) * Math.sin(deltaLongitude / 2) * Math.sin(deltaLongitude / 2);
const distanceMeters = 2 * earthRadiusMeters * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
const accuracyAccepted =
!locationPolicyArg.maxAccuracyMeters ||
locationEvidenceArg.accuracyMeters <= locationPolicyArg.maxAccuracyMeters;
const withinGeofence = distanceMeters <= locationPolicyArg.radiusMeters;
return {
matched: accuracyAccepted && withinGeofence,
distanceMeters,
accuracyAccepted,
evaluatedAt: Date.now(),
reason: !accuracyAccepted
? `Accuracy ${locationEvidenceArg.accuracyMeters}m exceeds allowed ${locationPolicyArg.maxAccuracyMeters}m`
: !withinGeofence
? `Location is ${Math.round(distanceMeters)}m away from ${locationPolicyArg.label || 'required area'}`
: undefined,
};
}
private createPairingPayload(
pairingTokenArg: string,
challengeArg: PassportChallenge,
@@ -450,6 +548,7 @@ export class PassportManager {
deviceLabel: optionsArg.deviceLabel,
requireLocation: false,
requireNfc: false,
locationPolicy: undefined,
requestedCapabilities: this.normalizeCapabilities(optionsArg.capabilities),
},
evidence: undefined,
@@ -602,6 +701,7 @@ export class PassportManager {
notificationTitle?: string;
requireLocation?: boolean;
requireNfc?: boolean;
locationPolicy?: plugins.idpInterfaces.data.IPassportLocationPolicy;
}
) {
const passportDevices = await this.getPassportDevicesForUser(userIdArg);
@@ -631,8 +731,9 @@ export class PassportManager {
audience: optionsArg.audience,
notificationTitle: optionsArg.notificationTitle,
deviceLabel: targetDevice.data.label,
requireLocation: !!optionsArg.requireLocation,
requireLocation: !!optionsArg.requireLocation || !!optionsArg.locationPolicy,
requireNfc: !!optionsArg.requireNfc,
locationPolicy: optionsArg.locationPolicy,
},
evidence: undefined,
notification: {
@@ -716,9 +817,21 @@ export class PassportManager {
throw new plugins.typedrequest.TypedResponseError('Passport signature invalid');
}
const locationEvaluation =
passportChallenge.data.metadata.locationPolicy && optionsArg.location
? this.evaluateLocationPolicy(passportChallenge.data.metadata.locationPolicy, optionsArg.location)
: undefined;
if (passportChallenge.data.metadata.locationPolicy && !locationEvaluation?.matched) {
throw new plugins.typedrequest.TypedResponseError(
locationEvaluation?.reason || 'Location evidence did not satisfy the office policy'
);
}
await passportChallenge.markApproved({
signatureFormat: optionsArg.signatureFormat || 'raw',
location: optionsArg.location,
locationEvaluation,
nfc: optionsArg.nfc,
});
@@ -738,6 +851,36 @@ export class PassportManager {
return passportChallenge;
}
public async rejectPassportChallenge(deviceIdArg: string, challengeIdArg: string) {
const passportChallenge = await this.CPassportChallenge.getInstance({
id: challengeIdArg,
'data.deviceId': deviceIdArg,
'data.status': 'pending',
});
if (!passportChallenge) {
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
}
if (passportChallenge.isExpired()) {
await passportChallenge.markExpired();
throw new plugins.typedrequest.TypedResponseError('Passport challenge expired');
}
await passportChallenge.markRejected();
await this.receptionRef.activityLogManager.logActivity(
passportChallenge.data.userId,
'passport_challenge_rejected',
`Rejected passport challenge ${passportChallenge.data.type}`,
{
targetId: passportChallenge.id,
targetType: 'passport-challenge',
}
);
return passportChallenge;
}
public async listPendingChallengesForDevice(deviceIdArg: string) {
const passportChallenges = await this.CPassportChallenge.getInstances({
'data.deviceId': deviceIdArg,