When a SCIM hard delete targets a profile that is still referenced
(e.g. completed document version signatures, FK RESTRICT), profile.Delete
fails with 23503 and poisons the surrounding transaction. The existing
deactivate fallback then ran on the aborted transaction and failed with
25P02, surfacing to the connector as an opaque 500 and eventually
disabling the bridge.
Wrap profile.Delete in a savepoint so the FK violation only rolls back
the delete attempt, leaving the outer transaction healthy for the
deactivate/archive fallback. Also map FK violations in Membership.Delete
to ErrResourceInUse for consistency with MembershipProfile.Delete.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
Update webhook events now carry a top-level "updatedFrom" field
alongside "data", containing a full snapshot of the entity as it was
before the update. This lets subscribers diff old vs new state (for
example the prior membership role on user:updated) without tracking
prior state themselves. It is a complete snapshot with the same shape as
"data", not a partial diff, so consumers select whatever fields they
need. The field is omitted for non-update events.
The webhook_data table gains a nullable updated_from JSONB column, and
webhook.InsertUpdateData enqueues both snapshots; InsertData delegates to
it with a nil updatedFrom so non-update callers are unaffected. Each
*:updated emission site snapshots the entity right after load, before
mutation: obligation, third-party, user (org and SCIM flows), document,
document-version, and document-version-approval-quorum. The document
emit helpers gained an optional updatedFrom argument threaded through to
the payload.
For document-version-approval-quorum:updated the snapshot requires an
extra query, so it is now gated behind the same subscription-existence
check the emitter uses: when no subscriber is configured the load is
skipped entirely rather than running (and potentially failing the
approval) for an event nobody receives.
Add integration tests (against a real Postgres, skipped when none is
reachable) covering the updated_from round-trip, the SQL NULL behavior
when no snapshot is provided, and the no-op when no subscription matches,
plus a unit test asserting updatedFrom is omitted from the payload when
absent.
Document the new field in the probod and n8n changelogs and the n8n
README.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
The source headers, LICENSE files, and license metadata had drifted
apart. Align the entire project to MIT:
- Convert every source-file header to the MIT text across all comment
styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including
SPDX-License-Identifier tags
- Set the root and cookie-banner LICENSE files to the MIT text with a
"MIT License" title line
- Switch the package.json license fields, Docker image label, and
cookie-banner README to MIT
- Update docs and the genmodels header generator accordingly
- Normalize copyright lines to a single format
(Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the
hello@getprobo.com and hello@probo.inc emails to hello@probo.com and
the comma-separated years to a hyphenated range
Genuine third-party references are intentionally left untouched: the
Lucide icon attributions (Lucide is ISC) and the trivy dependency
license allowlist.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
The nOAuth fix set trustProviderEmail to false, requiring the
email_verified claim. Microsoft never emits that claim, so the check
rejected every legitimate Microsoft login before the xms_edov check
was reached.
Restore trustProviderEmail to true and keep the required xms_edov
claim, which is the actual nOAuth mitigation: Azure sets it only after
verifying the issuing tenant owns the email's domain, so a token
lacking it is still rejected before any identity is matched.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
Stop trusting the email on its own: set trustProviderEmail to false so
email_verified is required, and additionally require the "xms_edov"
claim, which Azure sets only after verifying the issuing tenant owns
the email's domain. A token that lacks it is rejected before any
identity is matched.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
RequiresManagedResourceID only has meaning for a ManagedAPIKey provider:
ManagedConnectorReady consults it exclusively on that path. A non-managed
provider that set it would advertise normally with the requirement
silently doing nothing. Reject the combination at registration, matching
the ManagedAPIKey/SupportsAPIKey mutual-exclusion guard already in
Register, and cover it with a TestRegistry_Register subtest.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Cover gaps the connectors shipped without: the Railway and Crisp name
resolvers had no tests despite real branch logic (single vs multiple vs
zero workspaces, best-effort on error, empty-website short-circuit,
tier header), and the crispVerificationCode query was only unit-tested
at the HMAC layer, never through the live schema and authorization
stack.
Add TestRailwayNameResolver and TestCrispNameResolver alongside the
existing resolver tests, pin the Crisp driver's hardcoded MFA Unknown
and nil Active, and add an e2e TestCrispVerificationCode asserting the
code shape, determinism, organization binding, blank-input INVALID, and
viewer FORBIDDEN. The verification-code query needs no Crisp credentials
(only the always-set token secret and organization authorization), so it
runs against the default e2e deployment.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The Crisp ownership check returned a generic internal error when the
managed plugin token was unset without logging server side, unlike the
sibling plugin-ID branch and every other internal path in the file. A
deployment with the token unconfigured but the provider somehow surfaced
would produce an undiagnosable error. Log the condition first, mirroring
the plugin-ID branch.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The Crisp and Railway connectors each repeated their HTTP plumbing
across the driver, the name resolver, and (for Crisp) the subscription
settings fetcher: the same JoinPath/headers/Do for Crisp GETs and the
same marshal/POST/headers for Railway GraphQL. The Crisp and Scaleway
drivers also carried byte-identical owner/member role mapping and admin
checks.
Extract crispGet and railwayPost as package-private request helpers so
each call site owns only status handling, and lift the owner/member role
mapping into shared ownerMemberRoles/isOwnerRole helpers beside
activeFromStatus in driver.go. Name the Crisp base URL and tier header
as consts in probe.go's const block rather than inlining the literals,
matching the file's existing convention. Behavior is unchanged.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Match the Crisp and Yousign drivers: move the inline owner check out of
the AccountRecord literal into scalewayIsAdmin, alongside the existing
scalewayRoles/scalewayActive helpers. Behaviour is unchanged (only the
organization owner is an administrator).
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
IsAdmin was the one derived AccountRecord field computed inline in the
struct literal, while Crisp's roles and full name already go through
helpers and the sibling Yousign driver uses a yousignIsAdmin helper. Move
it to crispIsAdmin for consistency; behaviour is unchanged (only the
website owner is an administrator).
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
A managed (Model B) connector like Crisp needs both the Probo-held key
and a resource ID (the plugin ID) to connect, but the driver catalog
gated visibility on the key alone. A deployment that set the key without
the plugin ID (reachable through raw JSON config; the bootstrap env path
already requires both) would show Crisp as connectable and then fail
every attempt with an internal error.
Add a RequiresManagedResourceID flag to the registration and a
Registry.ManagedConnectorReady check that requires both before a managed
provider enters the catalog, so a half-configured provider stays hidden
instead of dead-ending at connect.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Crisp is a managed (Model B) connector: Probo holds one plugin token
server-side and each connection carries only a Website ID. Nothing
stops one organization from entering another organization's Website
ID, so prove control of the website before creating the connection.
Probo derives a per-(organization, website) verification code as an
HMAC over the token secret and exposes it through a new
crispVerificationCode query. The customer pastes it into the Probo
plugin's per-website settings; at connect time the resolver reads the
setting back through the managed plugin token and requires a
constant-time match before any row is written. The managed key and
plugin ID come from bootstrap, so the connector stays hidden until the
deployment configures them.
The settings fetch is injected so the create-time gate's branch wiring
is unit-tested (mismatch and not-subscribed reject, internal errors
stay generic, a matching code passes), and the managed-versus-client
key resolution is covered too.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
The recorded responses carried only the fields the drivers read.
Rewrite them to match the shape the real Scaleway, Yousign and
Railway APIs return (workspaces, status, account_root_user_id,
timezone-qualified timestamps), so the fixtures exercise the drivers
against realistic payloads. The discriminating assertions (email,
role, is_admin, active, MFA) are unchanged and the driver tests
still pass.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Four API-key, single-tenant (Pattern 3) connectors:
- Scaleway: secret key in the X-Auth-Token header plus an Organization ID
setting; GET /iam/v1alpha1/users (owner/member, status, two-factor),
per-connection BuildProbeURL.
- Yousign: Bearer API key; GET /v3/users (admin/owner/member, is_active);
production host with a static probe.
- Railway: Bearer account token; GraphQL me{workspaces{members}} aggregated
and deduplicated across workspaces; custom probe, since Railway returns
HTTP 200 with an errors body on a rejected token.
- Crisp: plugin token as HTTP Basic (identifier:key) plus a Website ID
setting and the X-Crisp-Tier header; GET /v1/website/{id}/operators/list,
custom probe and name resolver.
Scaleway and Crisp carry a required extra setting, so the console add-source
dialog maps organizationId/websiteId onto their scalewayOrganizationId and
crispWebsiteId API-key inputs; without that mapping the value is silently
dropped and the create is rejected.
Cassette-backed driver tests plus unit tests for the cross-workspace
deduplication, the probe contracts and the role/MFA helpers.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
Build the public Updates pages in the compliance portal: a
cursor-paginated list of sent mailing-list updates and a detail view
for a single update, replacing the previous stub page.
Add a MailingListUpdate case to the trust API node resolver, guarded so
only SENT updates belonging to the current trust center's mailing list
are exposed, so the detail page can load an update by URL.
Add a Prev/Next Pagination primitive to the v2 UI kit. Page numbers are
omitted because cursor pagination cannot derive an ordinal page index;
each arrow only shows when its page exists while keeping its slot
reserved so a visible arrow never shifts position.
Relocate the shared MailingListUpdateListItem to its own component
folder and wrap each row in a link to the detail page, so both the home
recent-updates section and the list navigate to detail.
Signed-off-by: Émile Ré <emile@probo.com>
Address the review comments on the subprocessors work:
- Reject invalid category/country filter values in the Subprocessors
resolver with an INVALID error instead of relying solely on transport
coercion, so a malformed request fails fast and explicitly.
- Use pgx.StrictNamedArgs in the new distinct facet queries so missing
or extra SQL placeholders stay detectable, matching sibling queries.
- Default a nil ThirdPartyFilter at the service boundary to avoid a nil
dereference in the coredata list/count paths.
- Expose the category group label as an aria heading for assistive tech.
- Add the missing space in the Select "Selected:" story label.
Signed-off-by: Émile Ré <emile@probo.com>
The subprocessors toolbar derived its category and region filter
options client-side from a second, unfiltered subprocessors(first: 250)
fetch, shipping up to 250 rows purely to compute two small facet sets
and silently capping the options at that limit.
Expose subprocessorCategories and subprocessorCountries on TrustCenter,
each backed by a DISTINCT query over the organization's third parties
scoped to show_on_trust_center, and read them directly in the toolbar.
The page now issues one filtered list query plus two tiny arrays, and
the options only ever include values that can actually return results.
Signed-off-by: Émile Ré <emile@probo.com>
Subprocessor filtering for the compliance portal happens in the backend
rather than the client. Add a SubprocessorFilter (query, category,
country) to the trust API's subprocessors connection, thread it through
the resolver and service, and extend the coredata ThirdParty filter with
category equality and country array membership. The connection stores the
filter so totalCount reflects the filtered set. Add e2e coverage for the
new filtering.
On the frontend, convert the page to a refetchable fragment whose filter
arguments are driven by URL-persisted, debounced toolbar state (category
and region selects plus a search field), populate the dropdowns from an
unfiltered facet selection, and offer to clear filters from the empty
state.
Signed-off-by: Émile Ré <emile@probo.com>
Any self-provisioned trust center visitor could accept another
visitor's NDA signature or inject audit-trail events into it by
supplying its GID, since AcceptSignature and RecordEvent trusted the
client-supplied signature ID without checking it belonged to the
caller (GHSA-22xj-f767-ppw6). SignerEmail/ActorEmail are always
derived from the verified session identity, never client input, so
comparing them against the signature's stored SignerEmail in
pkg/esign/service.go closes the hole at its root without touching the
resolver-level authorization already in place elsewhere.
Adds an e2e regression test that self-provisions two trust center
visitors through the real magic-link flow and confirms one cannot
touch the other's signature.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Drop the redundant embedded Resolver selector flagged by staticcheck
(QF1008) in the membership and organization permission resolvers, and
add the whitespace wsl_v5 expects around the attribute range loop and
the final return in permission.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
Replace the deny-based restriction on granting OWNER with role-scoped
allow policies so authorization fails closed: admins may create and
update memberships only when the assigned role is not OWNER, and the
absence of a target role no longer implies permission.
To keep console UI gating accurate without loosening the base grants,
the permission field gains an optional typed options argument
(PermissionOptionsInput) that forwards target_role into the dry-run
authorization. Only the two role-related console calls (create user,
update membership) pass it; the OWNER option stays hidden for admins via
the existing assignable-roles helper.
Add a non-regression test that an admin cannot promote a member to OWNER
while still being able to change members between non-owner roles.
An organization ADMIN could hard-remove members, including OWNERs, because
removeUser (connect and MCP) only checked the weaker iam:membership-profile:delete
gate. Authorize the owner-only iam:membership:delete instead, and expose the
source attribute on MembershipProfile so the owner grant's non-SCIM condition
can match.
Consolidate ownership-grant authorization into policy for both createUser and
updateMembership: each resolver passes the requested role as a target_role
attribute and ADMIN is denied granting ownership via deny-create-owner /
deny-promote-owner. target_role is distinct from resource.role, which is the
target's current role and guards editing existing owners. With no callers left,
the iam:membership-role:set-owner action (grant and OAuth2 scope) is removed.
Also pass the authorized scope through to the RemoveUser/CreateUser services,
gate the console Remove action on iam:membership:delete, and add regression
tests plus a changelog entry.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
The third-party vetting agent runs a suite of HTTP "security" tools on
the internal worker network against a caller-supplied URL that is only
validated for length and charset, not host. Several tools reached
internal, loopback, and link-local addresses:
- analyze_csp used a bare http.Client with no host validation, no
redirect control, and no rebinding-safe transport, reflecting the
target's CSP header back to the caller.
- check_security_headers, fetch_robots_txt, and fetch_sitemap
validated only the initial host, then followed 3xx redirects with an
ordinary client, yielding full-read SSRF via a redirect to an
internal address.
- check_cors validated the URL but still dialed through an ordinary
transport, leaving it exposed to DNS-rebinding TOCTOU.
Route every one of these clients through the house-standard
httpclient.DefaultPooledClient(WithSSRFProtection()), which rejects
dials to loopback, private, CGNAT, link-local, ULA, IPv4-mapped, and
reserved ranges on the resolved peer IP at connect time (defeating DNS
rebinding on every redirect hop) and refuses cross-origin redirects.
download_pdf moves onto the same client, and the now-unused local
netcheck.NewPinnedTransport is removed. analyze_csp also gains an
up-front ValidatePublicURL check for a clean early error and scheme
enforcement.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
The DocumentVersionSignatureFilter exposed a field named `state` that
actually filters on the signatory's profile state, which was ambiguous
next to the signature `states` field. Rename it to `profileState`
(GraphQL) / `profile_state` (MCP) across the schema, spec, resolvers,
console app, and n8n node for clarity.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
Normalize Anthropic thinking budgets while building message parameters so budget_tokens stays below max_tokens with response headroom. When the configured max token budget is too small to support Anthropic's minimum thinking budget, omit thinking for that request instead of sending an invalid payload.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Sacha Al Himdani <SachaProbo@users.noreply.github.com>
The public trust API derived its authorization scope from client-supplied
global IDs, so a visitor on one trust center could resolve nodes, export
audit-report PDFs, and read or mutate electronic signatures belonging to
another organization (cross-tenant access).
Every trust API resolver now derives its scope from the active compliance
page's organization via compliancepage.ScopeFromContext, so reads are always
confined to the page's tenant. Cross-tenant or unknown IDs surface as
not-found instead of leaking data or returning a 500. Active/presence is
enforced upstream by the id and presence middlewares.
esign's signature operations (GetSignatureByID, AcceptSignature, RecordEvent)
now take a caller-provided scope instead of deriving one from the requested
ID, so signature reads and mutations are tenant-scoped at the source. This
removes the need for a resolver-level authorization helper; RecordEvent also
verifies signature ownership within scope before recording, since the event
foreign key is not tenant-composite.
Adds e2e non-regression tests covering owning vs. foreign trust center report
export and the generic node(id:) resolver.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
The available document access list backing Grant All included documents
whose trust center visibility is PRIVATE but that have no published
version. Require a published major so the grantable set matches the
request-all filter and the Slack notification, which already skip drafts
and hidden documents.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
The trust center access-request Slack notification listed every document
referenced by a document access, including drafts with no published
version and documents whose trust center visibility is NONE. Skip those
so the message only surfaces documents a requester could actually be
granted.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
Several HTTP entry points still parsed RemoteAddr directly, so behind
a layer-7 proxy they recorded the load balancer IP instead of the
signer's. Route NDA acceptance, signing events, document sign/approve,
and session updates through clientip.Extract, which honors Forwarded
and X-Forwarded-For when trustedproxy allows them.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
An organization ADMIN could mint an OWNER membership via createUser, which
only gated iam:membership-profile:create and bypassed the owner-only
iam:membership-role:set-owner check that updateMembership already enforces.
Gate the requested role in both createUser entry points (connect resolver
and the MCP CreateUserTool) with an additional set-owner authorization when
the role is OWNER, mirroring updateMembership. Add a regression test that
locks the ADMIN/OWNER privilege boundary the fix relies on.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
The CLI still defaulted to *.console.getprobo.com for region
selection, examples, and the non-interactive fallback host. Align
those with the current SaaS hostnames so new logins store the right
config keys.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Third-party compliance report PDFs were rejected above 10MB in the
upload dialog. Increase the client-side Dropzone limit and enforce the
same cap on the backend file validator.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add a resource-oriented set of webhook events for the document
lifecycle. Each event carries the document plus only the sub-resource
it concerns (version, signature or approval).
Events:
- document.created / updated / archived / unarchived / deleted
- document.version.created / updated / published / rejected / deleted
- document.version.signature.requested / signed / cancelled
- document.version.approval.requested / approved / rejected / voided
Wires the new types through the migration, Go enum, GraphQL schema,
CLI, n8n nodes and the console webhooks settings UI.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
The n8n package could already manage webhook subscriptions through API
CRUD, but had no way to start a workflow when Probo emitted an event. A
user had to drop in the generic Webhook node, create a subscription by
hand, and verify the HMAC signature themselves.
Add a ProboTrigger node that owns the subscription lifecycle: it creates
the subscription on activation pointing at n8n's generated webhook URL,
re-checks and re-registers it if the URL drifts, and deletes it on
deactivation. The webhook handler recomputes the HMAC-SHA256 over the raw
request body and compares it constant-time against the delivered
signature, failing closed when the bytes or headers are absent.
Drop the MEETING_* event choices from the webhook create and update
operations and the CLI event list. They are not part of the backend
WebhookEventType enum, so selecting them only produced API rejections.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
probod-bootstrap was writing empty strings and stub blocks such as
`esign: {}` into generated YAML. The post-marshal prune pass caused
part of that by stripping empty leaf strings while leaving empty
parent maps behind.
Drop the prune round-trip in WriteConfig and rely on struct-level
omitzero/omitempty tags plus custom IsZero() helpers on probodconfig.
Only include LLM providers when an API key is set, use a nil map for
extra API headers, and extend the dev-config Makefile recipe with the
local dev defaults already documented in .env.example.
Config loading is unchanged: omitted keys still decode to Go zero
values.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>