diff --git a/src/esign/index.ts b/src/esign/index.ts index 904f27a..e91c4de 100644 --- a/src/esign/index.ts +++ b/src/esign/index.ts @@ -57,6 +57,7 @@ function catalog(): AccessCatalog { export const esign = createEsignService({ store: createPgStore(DB), + directory: idp?.directory ?? undefined, drives: resolveDrives(), mailer: { signatureRequest: sendSignatureRequestEmail, signedDocument: sendSignedDocumentEmail }, logger, diff --git a/src/esign/service.ts b/src/esign/service.ts index c5af3f9..0b124c7 100644 --- a/src/esign/service.ts +++ b/src/esign/service.ts @@ -58,6 +58,8 @@ type Logger = { export type ServiceOptions = { store: EsignStore + /** Registered IdP users, including people who have not opened Signatures yet. */ + directory?: () => Promise<{ sub: string; name: string; email: string | null }[]> /** The configured libraries, in the order the dashboard offers them; empty without OneDrive. */ drives: Drive[] mailer: Mailer @@ -604,10 +606,18 @@ export function createEsignService(options: ServiceOptions) { /** Everyone who can be picked as a signer, with the drives whose documents they may sign. */ async listProfiles(): Promise<(Profile & { drives: string[] })[]> { const rules = await accessRules() - return (await store.listProfiles()).map((profile) => ({ - ...profile, - drives: drives.filter((d) => maySign(rules, profile.sub, d.id)).map((d) => d.id), - })) + const profiles = await store.listProfiles() + const registered = options.directory ? await options.directory() : profiles + const bySub = new Map(profiles.map((profile) => [profile.sub, profile])) + return registered + .filter((person) => !options.directory || options.access?.(person.sub)?.permissions.includes("esign:sign")) + .map((person) => ({ + ...bySub.get(person.sub), + ...person, + fiscalCode: bySub.get(person.sub)?.fiscalCode ?? null, + certificateSubject: bySub.get(person.sub)?.certificateSubject ?? null, + drives: drives.filter((d) => maySign(rules, person.sub, d.id)).map((d) => d.id), + })) }, async resetIdentity(sub: string) { @@ -668,13 +678,20 @@ export function createEsignService(options: ServiceOptions) { throw new EsignError("bad_request", `Choose between 1 and ${MAX_SIGNERS} signers`) if (new Set(input.signers.map((s) => s.sub)).size !== input.signers.length) throw new EsignError("bad_request", "Each person can sign once") + const registered = options.directory + ? new Map((await options.directory()).map((person) => [person.sub, person])) + : null const signers: { sub: string; name: string; role: string | null; places: PlaceJson[] }[] = [] + const profilesToSync: { sub: string; name: string; email: string | null }[] = [] for (const s of input.signers) { - const profile = await store.getProfile(s.sub) - if (!profile) throw new EsignError("bad_request", "A signer is not a dashboard user") + const person = registered ? registered.get(s.sub) : await store.getProfile(s.sub) + if (!person) throw new EsignError("bad_request", "A signer is not a registered user") + if (registered && !options.access?.(s.sub)?.permissions.includes("esign:sign")) + throw new EsignError("bad_request", `${person.name} is not allowed to sign`) + if (registered) profilesToSync.push(person) signers.push({ sub: s.sub, - name: profile.name, + name: person.name, role: s.role?.trim() || null, places: validatePlaces(s.places, document.pages), }) @@ -724,6 +741,7 @@ export function createEsignService(options: ServiceOptions) { message: input.email.message?.trim() || null, } : null + for (const person of profilesToSync) await store.upsertProfile(person) const request = await store.createRequest({ title: input.title.trim(), ordered: input.ordered, diff --git a/src/idp/index.ts b/src/idp/index.ts index ed260fc..f744773 100644 --- a/src/idp/index.ts +++ b/src/idp/index.ts @@ -9,6 +9,7 @@ import { serviceTokenSource, verifyAccessToken, } from "@polinetwork/auth-kit" +import { z } from "zod" import { env } from "@/env" import { logger } from "@/logger" import { authenticate, type RequestAuth } from "./auth" @@ -16,6 +17,7 @@ import { dbPersistence } from "./persistence" /** The projection this backend reads from the IdP (RFC v3 ยง5.2). */ const PROJECTION = "backend" +const signerDirectory = z.array(z.object({ sub: z.string(), name: z.string(), email: z.string().nullable() })) function report(component: string) { return (error: unknown) => { @@ -51,6 +53,7 @@ function configure() { let snapshot = null let events = null + let directory: (() => Promise>) | null = null if (env.OAUTH_INTERNAL_RESOURCE_URI && env.OAUTH_BACKEND_CLIENT_ID && env.OAUTH_BACKEND_PRIVATE_JWK) { const tokens = serviceTokenSource({ clientId: env.OAUTH_BACKEND_CLIENT_ID, @@ -69,6 +72,17 @@ function configure() { pollIntervalMs: env.IDP_SNAPSHOT_POLL_INTERVAL_MS, onError: report("access snapshot"), }) + directory = async () => { + const url = new URL("/api/internal/esign-users", endpoints.snapshotUrl) + const token = await tokens.getToken() + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10_000), + }) + if (response.status === 401) tokens.invalidate() + if (!response.ok) throw new Error(`IdP signer directory returned ${response.status}`) + return signerDirectory.parse(await response.json()) + } events = createAccessEventHandler({ keyStore, issuer: endpoints.issuer, @@ -84,6 +98,7 @@ function configure() { return { keyStore, snapshot, + directory, /** Fetch API handler for `POST /internal/events`, or null without a snapshot. */ events, verify: (token: string) => verifyAccessToken(token, { issuer: endpoints.issuer, audience, keyStore }), diff --git a/tests/esign-service.test.ts b/tests/esign-service.test.ts index c2f0b9b..c424191 100644 --- a/tests/esign-service.test.ts +++ b/tests/esign-service.test.ts @@ -26,6 +26,7 @@ function setup( allowTestCertificates?: boolean revocation?: "good" | "revoked" | "unknown" policy?: "require" | "prefer" | "off" + directory?: () => Promise<{ sub: string; name: string; email: string | null }[]> } = {} ) { let clock = new Date("2026-10-17T10:00:00Z").getTime() @@ -42,6 +43,7 @@ function setup( store, drives: [drive, archive], access: (sub) => people.get(sub) ?? null, + directory: opts.directory, mailer, logger: { info: () => {}, warn: () => {}, error: () => {} }, dashboardUrl: "https://admin.example.org", @@ -116,6 +118,36 @@ describe("e-signing service", () => { env = setup() }) + it("lists and accepts registered eligible signers before their first visit", async () => { + const fresh = { sub: "sub-new", name: "New Signer", email: "new@example.org" } + env = setup({ directory: async () => [fresh, { sub: "sub-no-access", name: "No Access", email: null }] }) + env.people.set(fresh.sub, { roles: [], permissions: ["esign:sign"] }) + expect(await env.store.getProfile(fresh.sub)).toBeNull() + expect((await env.service.listProfiles()).map((person) => person.sub)).toEqual([fresh.sub]) + + const { document } = await env.service.stageUpload(manager, "request.pdf", await samplePdf()) + await expect( + env.service.createRequest(manager, { + title: "Request", + documentId: document.id, + ordered: true, + signers: [{ sub: "sub-no-access", role: null, places: [{ ...placement(0), kind: "signature" }] }], + output: null, + email: null, + }) + ).rejects.toThrow("No Access is not allowed to sign") + const request = await env.service.createRequest(manager, { + title: "Request", + documentId: document.id, + ordered: true, + signers: [{ sub: fresh.sub, role: null, places: [{ ...placement(0), kind: "signature" }] }], + output: null, + email: null, + }) + expect(request.signers[0]).toMatchObject({ sub: fresh.sub, name: fresh.name }) + expect(await env.store.getProfile(fresh.sub)).toMatchObject(fresh) + }) + it("collects signatures in order, then uploads and emails the signed PDF", async () => { const request = await prepareRequest(env) expect(request.turn).toEqual([secretary.sub]) @@ -290,7 +322,8 @@ describe("e-signing service", () => { const match = /\/ByteRange (\[[^\]]+\])/.exec(text) if (!match) throw new Error("no ByteRange") const range: [number, number, number, number] = [0, original.length, gap.placeholder.pdf.length, 0] - const entry = `[${range.join(" ")}`.padEnd(match[1].length - 1, " ") + "]" + const padded = `[${range.join(" ")}`.padEnd(match[1].length - 1, " ") + const entry = `${padded}]` gap.placeholder.pdf.set(Buffer.from(entry), match.index + "/ByteRange ".length) gap.placeholder.byteRange = range gap.signedAttrs = signedAttributes(sha256(signedBytes(gap.placeholder.pdf, range)), secretarySigner.cert)