The trust_center_aliases table only allowed aliasing trust center
resources. Rename it to resource_aliases and scope uniqueness by
tenant rather than organization so any resource can carry an alias.
Replace the TrustCenterAlias coredata type with a ResourceAlias type
that loads and upserts aliases by resource ID.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Tighten redirect URI validation for metadata documents, honor
Cache-Control no-store when caching fetched documents, and resolve
clients on the same transaction as authorization. Load
external_client_id from the database and parse unbounded max-stale
directives in cachecontrol.
Signed-off-by: Bryan Frimin <bryan@probo.com>
MCP connectors such as ChatGPT and Claude register via HTTPS
client_id URLs instead of pre-provisioned GIDs. Fetch and cache
their metadata documents, upsert clients on first use, and
advertise CIMD in OIDC discovery when allowed URLs are configured.
Signed-off-by: Bryan Frimin <bryan@probo.com>
The tracker-mapping worker re-arms same-banner siblings after a pattern
resolves a vendor. Its predicate only excluded promoted siblings
(third_party_id IS NULL), but since org-party auto-creation was dropped a
pattern can resolve a common third party yet never gain an org
third_party_id. Those siblings, and terminal first-party ones, stayed
eligible forever, so every cascade step re-enqueued and reprocessed them,
amplifying Process runs to O(N^2) per banner. The deadlock fix in the
last release removed the rollbacks that had accidentally throttled the
cascade, so the latent amplification surfaced as an INFO-log flood.
Tighten the re-enqueue to skip siblings already linked to a catalog row
that carries a common third party or marked FIRST_PARTY, dropping
per-banner reprocessing back to O(N). Also demote the two per-run handler
logs ("mapped tracker pattern", "re-enqueued unmapped sibling tracker
patterns") to Debug so routine processing no longer logs at INFO.
Signed-off-by: Émile Ré <emile@probo.com>
The tracker-mapping worker runs many Process calls in parallel. In
Phase 4 a single transaction locked the worker's own claimed pattern
row via UpdateMapping and then locked sibling rows on the same banner
via the re-enqueue. Two workers mapping sibling patterns on one banner
each held their own row and waited on the other's, forming a lock cycle
that Postgres aborted with deadlock detected (40P01).
Split the sibling re-enqueue into its own short transaction that runs
after the mapping commits, so the claimed-row lock is released before
any sibling row is locked. Also take the sibling UPDATE row locks in a
deterministic id order through an ORDER BY id ... FOR UPDATE subquery,
so overlapping re-enqueues can no longer invert lock order between
themselves. The re-enqueue only flags siblings, so deferring it past
the commit is safe and lets reprocessed siblings observe committed data.
Signed-off-by: Émile Ré <emile@probo.com>
Surface every CookieSource value in the console: the trackers page
filter was missing the HTTP option and the source badge helper had no
EXTENSION case, so HTTP-sourced rows could not be filtered and
extension-sourced rows rendered the raw enum string.
On the backend, the mark-first-party verdict now blanks the stale
description on both the catalog row and its uncategorised org tracker
patterns. A terminal non-third-party row keeps no vendor link, so a
description naming the (now-cleared) vendor would be misleading; the
mapping worker only copies descriptions into empty rows and never
clears them, so clearing is done explicitly here.
Signed-off-by: Émile Ré <emile@probo.com>
Device logins only requested OIDC scopes while the authorizer now
gates API calls on v1:* scopes. Register the full scope set on the
well-known prb client, request it at login via CLIClientScopes, and
cover the device flow in e2e.
Collapse API scopes under an accordion on the consent screen and
document scope sync for future namespace additions.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Let users create, list, and revoke manual bearer tokens from
/me/oauth-tokens, scoped to their identity rather than an
organization. Manual tokens store a null client_id and are
authorized with a self-manage IAM policy.
Wire Connect GraphQL on Identity (list, create, revoke), add
console UI with scoped create flow and credentials dialog, and
cover the flow in e2e tests. Fix list pagination ordering and
keep the Relay connection in sync after create.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
OAuth grant tokens issued before API scope enforcement only stored
OIDC scopes. Set every existing iam_oauth2_access_tokens row to the
full supported scope set so bearer tokens keep working under the
OAuth2 scope gate.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Register v1 API scopes in coredata, advertise them in OIDC discovery
and protected-resource metadata, show them on the consent screen, and
enforce scope-to-action mapping in the IAM Authorizer before policy
evaluation.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
The Upsert behavior is documented in the Go comment above the
method; inline SQL comments duplicated that and cluttered the
query.
Signed-off-by: Émile Ré <emile@probo.com>
Three paths could leave a catalog row's attribution out of step with its
common_third_party_id. A FIRST_PARTY reclassification in the mapping
worker kept a stale org ThirdParty link instead of clearing it. The
upsert requeued terminal FIRST_PARTY rows for enrichment on a vendor
they never adopt, since the vendor-preservation clause nulls it. And the
proboctl upsert command did not normalize the verdict when an operator
linked or unlinked a vendor without passing --attribution.
Clear the org link on a first-party verdict, exclude FIRST_PARTY rows
from the enrichment requeue, and have the CLI downgrade THIRD_PARTY to
UNDETERMINED on unlink and promote UNDETERMINED to THIRD_PARTY on link.
Signed-off-by: Émile Ré <emile@probo.com>
The tracker-pattern catalog was binary (linked to a vendor or not), so
generic and first-party artifacts (loglevel keys, wallet-extension keys,
an org's own trackers) were retried forever and, once one row was wrongly
attributed, re-propagated to every organization with no re-check.
Give catalog rows a terminal attribution verdict (UNDETERMINED,
THIRD_PARTY, FIRST_PARTY): FIRST_PARTY short-circuits the whole mapping
pipeline so the artifact is never attributed again. Gate deterministic
vendor adoption behind a trust bar so only curated/operator rows
auto-propagate; lower-confidence agent/heuristic rows are reused as hints
and re-resolved, and an independent agent re-confirmation corroborates and
promotes them. Make the mapping agent emit an evidence source and reject
any attribution that lacks concrete evidence, and let it declare a
first-party verdict. Skip the speculative agent for PRE_EXISTING-source
patterns, whose low signal invites invented vendors.
Add proboctl "ctp mark-first-party" and an --attribution list filter to
audit and remediate existing wrong links, and a cursor rule documenting
migration naming so the timestamp is taken from date -u, not invented.
Signed-off-by: Émile Ré <emile@probo.com>
Replace the immediate per-document approval email and the manual
"send signing notifications" action with a single debounced worker that
batches pending requests per recipient and organization.
The worker (go.gearno.de/kit/worker) polls on an interval (default 5m)
and claims one (organization, recipient) group at a time, sending one
consolidated signing email and/or one approval email per recipient/org
that lists every document awaiting their signature or approval. The
claim is a conditional UPDATE that doubles as concurrency-safe dedup, so
several workers never email the same group twice.
Each request is notified once it has been pending past the debounce
delay (default 15m), then reminded at 1x, 2x and 3x the reminder
interval (default 1 day) after the previous email, after which it stops.
New last_notified_at and notification_count columns on signatures and
approval decisions drive the debounce, the widening reminder cadence and
the four-email cap.
Email copy lists each document with its title, type and a deep link to
the employee page. Removed the inline approval-on-publish email, the
SendSigningNotifications service method/mutation/MCP tool, its IAM action,
and the related console UI and n8n operation.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
OIDC login dropped organization_id before the provider redirect, so
callbacks with an existing matching root session never created an org
child session. Persist organization_id in OIDC state, open the child
session on callback, and forward the parameter from the sign-in UI.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Three crash- and migration-recovery gaps in the unified enrichment
model left rows stuck or misclassified:
- Upsert re-armed a blank, newly-linked tracker pattern without
clearing its prior enrichment payload. A crash between the worker's
claim and persist then left the row with a stale payload, so the
stale-recovery sweep (which only catches rows with a null payload)
skipped it forever. Clear enrichment on re-arm so the row reads as
not-yet-completed again, and pin the behavior with a test.
- The migration added last_enrichment_attempt_at to
common_third_parties without seeding it. Rows with prior attempts
kept a NULL clock and could never satisfy the stale-reset predicate.
Backfill from updated_at, the historical claim-time proxy.
- The migration switched the tracker-pattern enriched-state source to
the enrichment payload without backfilling rows previously marked by
enriched_at, making already-enriched rows read as unenriched.
Seed a provenance sentinel for rows that carried the old done-flag.
Signed-off-by: Émile Ré <emile@probo.com>
Make common_tracker_patterns and common_third_parties share one
enrichment-tracking model and fix the misleading proboctl status.
Both tables now carry the enrichment JSONB provenance payload, an
enrichment_attempts counter, and a last_enrichment_attempt_at clock.
On common_tracker_patterns the enriched_at done-flag is renamed to
last_enrichment_attempt_at and stamped at claim time, so it is truthful
to "attempt" rather than "success". A row is considered to have been
through the workflow when it carries an enrichment payload, not when a
timestamp is set, which lets stale recovery key off the payload being
absent with budget remaining, exactly like common_third_parties.
The claim path reads the attempt counter and timestamp back via
RETURNING so the in-memory receiver matches the database clock instead
of a separate app-side time.Now.
The enricher builds a per-field provenance payload (description and
third-party outcomes plus the mapping attribution) and persists it via
UpdateEnrichment, named to mirror the common-third-party sibling. The
common pattern enrichment worker gains a max-attempts ceiling so a
permanently failing row stops looping.
proboctl now shows "enriched" only when every field the last run
recorded an outcome for resolved a value, otherwise "partial (X/Y)",
replacing the misleading "enriched (no description)" label.
Signed-off-by: Émile Ré <emile@probo.com>
Unbounded LoadAll* loaders materialised an entire result set in one
query with no ceiling. A table that is small in development can grow
without bound in production, so these loaders were a latent memory
and query-time hazard.
Remove the LoadAll* methods from pkg/coredata and walk the cursor-
paginated LoadBy* siblings instead through a shared page.LoadAll
helper. The helper advances a MaxCursorSize forward cursor until the
result set is exhausted and concatenates the pages. It caps a single
call at MaxLoadAllPages (20) batches of 500 rows and errors past that
rather than materialising an unbounded set, so a runaway caller fails
loudly instead of exhausting memory.
Callers that genuinely need every row now express that explicitly,
and the coredata load-naming rule and docs are updated to discourage
new unbounded loaders.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
Long role strings in the access review table broke row layout when
drivers joined many roles into one comma-separated value. Expose
roles as a string array in GraphQL by splitting the stored role at
the API layer, and render the first three roles as badges with a
"+X more" popover for the rest.
Closes ENG-459.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Employee document signatures recorded an acknowledgment with no
cryptographic proof, unlike document approvals which already create
and accept an electronic signature on every decision.
Mirror the approval flow on the sign path: generate the signed
document PDF, create-and-accept an esign record, and persist its id
on the document_version_signatures row through a new
electronic_signature_id column. Capture the signer IP and user agent
in the resolver, and re-check the published/archived preconditions
inside the transaction so the seal cannot race document state.
Make the consent wording a single backend source of truth shared by
the text that is sealed and the text shown in the UI. Define
DocumentSignatureConsentText and DocumentApprovalConsentText in the
probo service package and the NDA copy in the trust service, each
owned by the flow that uses it, and stop esign from appending the
generic clause to caller-provided consent text so approvals no
longer seal a duplicated sentence.
Expose the resolved consent text through GraphQL on
EmployeeDocumentVersion and DocumentVersionApprovalDecision, and have
the signing, approval, and NDA pages render it from the API instead
of hard-coded strings, mirroring how the NDA page already worked.
Align the wording with the actual interaction: the buttons read
"Review and sign" and "Review and approve", the clauses reference
those actions, and the inaccurate "typing my full name" phrasing is
removed everywhere.
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
Expose campaign sources as first-class nodes, paginate fetch attempts
instead of denormalized status fields, and bind entries to their
campaign snapshot. Update GraphQL, MCP, CLI, console, and e2e coverage
to match.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Source category was never surfaced in the product and added noise to
snapshots and APIs. Remove the enum, columns, and service fields so
campaign sources track only the identity fields reviewers need.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Fresh installs already had csv_data on campaign sources from the
initial migration; add a follow-up migration to backfill existing
rows from the live source snapshot.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Decouple each campaign from the live access-review sources it was started
with by introducing a per-campaign source snapshot table
(access_review_campaign_sources). The snapshot captures the source name,
category, and connector at start time, so a review remains coherent even
after the underlying source is edited or deleted. Fetch tracking becomes
an append-only log (access_review_campaign_source_fetch_attempts) that
preserves every attempt with its own status and error rather than
overwriting a single row.
Rename the shared access-review tables and enums to use a consistent
access_review_ prefix throughout:
access_entries → access_review_entries
access_sources → access_review_sources
access_source_category → access_review_source_category
access_entry_* → access_review_entry_*
The same rename propagates to every coredata type, service, GraphQL
schema, MCP specification, CLI command, frontend component, and e2e test.
The accessreview package gains dedicated actions.go and policies.go files
for its own IAM policy set, mirroring the agentrun package pattern.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Columns must not carry DEFAULT clauses. The DEFAULT only backfills
existing rows when the non-nullable column is added, so drop it in the
same migration; inserts already supply enrichment_attempts explicitly.
Signed-off-by: Émile Ré <emile@probo.com>
Add `proboctl common-third-party reenrich` to re-arm the async
enrichment worker for selected catalog rows, and `stats` to summarize
the catalog by enrichment state and last run status. Rows are selected
verbatim via --id/--slug or across the catalog via
--category/--keyword/--state/--status, gated by --dry-run and --yes.
Extend `list` with --state/--status filters and STATE/STATUS columns,
and `show` with enrichment state, attempts, last run status, error,
per-field provenance, and discovered domains.
Back these with CommonThirdPartyFilter state/status/IDs filters plus
CommonThirdParties.LoadAllIDs and RequestEnrichmentByIDs. The latter
stamps enrichment_requested_at and resets the attempt counter while
preserving the existing payload, so the worker merge keeps curated and
human-edited provenance.
Also simplify exactLabelMatch to use slices.Contains.
Signed-off-by: Émile Ré <emile@probo.com>
When the common-third-party enrichment worker discovers owned domains
for a vendor, tracker patterns that were detected and left unmatched
before those domains were known had nothing to re-trigger them.
Add RequestMappingForUnmappedByInitiatorDomains, a global re-arm that
stamps mapping_requested_at on still-unmapped patterns whose detected
trackers share one of the new domains, and call it from the enrichment
worker's persist step for newly-inserted domains only. The mapping
worker then re-resolves them through its existing domain-overlap path.
Targeting is limited to patterns with no resolved vendor (no org third
party and an absent or unlinked catalog row), so a pattern already
attributed to this or any other vendor is never disturbed.
Signed-off-by: Émile Ré <emile@probo.com>
Introduce a poll-based worker that fills the global common_third_parties
catalog (URLs, headquarter address, legal name, certifications, logo)
so each tenant no longer starts from sparse, name-only rows. Enrichment
is requested at row creation by ResolveOrCreateCommonThirdParty; curated
seed rows are not enqueued, to avoid a re-seed storm.
The pipeline uses two specialized agents plus a deterministic logo step.
Agent A (company profile) resolves legal name, headquarter address, and
the canonical website over web search; its website and legal name feed
Agent B and the logo step. Agent B (compliance docs) resolves the legal
document URLs, trust/security/status pages, and certifications using the
browser read-only toolset (gated on ChromeDPAddr) plus web search. The
logo step restores pkg/webinspect as a pure deterministic package and
stores the discovered icon in S3, linked via logo_file_id.
Each agent returns per-field value/confidence/source_url. The worker
writes a column only when confidence clears a configurable threshold and
the field is not externally owned (seed or human), and always records
full per-field provenance in a new enrichment JSONB column so re-runs
fill only gaps and human edits are never clobbered. New bookkeeping
columns (enrichment_requested_at, enrichment, enrichment_attempts) back
the claim queue and stale recovery; agents run outside transactions and
results persist in one final transaction.
The worker is opt-in: it no-ops unless its agent provider is configured.
Signed-off-by: Émile Ré <emile@probo.com>
Replace the bare `getprobo.com` domain and the `www.getprobo.com`
marketing host with `probo.com` / `www.probo.com` across the codebase.
Functional subdomains (app, console, notification, custom, test,
cookie-banner, compliance) keep their existing `getprobo.com` hosts,
and changelog entries are left untouched.
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
The tracker-mapping worker had been reduced to catalog resolution only,
which removed not just the auto-creation of an org ThirdParty but also
the auto-linking of an existing one. Only the creation needed to go: it
raced the load-then-create check and produced duplicate vendors.
Restore the full org ThirdParty resolution (exact common-id link,
sibling direct-link, high-confidence heuristic, and the disambiguation
agent) and remove only the CreateFromCommon branch and its
categorisation gate. When nothing matches, the worker now leaves
third_party_id unset rather than creating a vendor; creation happens
exclusively through the explicit ImportFromCommon action. Drop the
now-dead CreateFromCommon helper and rename match.go to common_match.go.
Fix a latent test bug surfaced by actually running the DB-backed suite
(skipped in CI without Postgres): the heuristic-match candidate lacked
Level 1, so the level-filtered candidate loader excluded it and the old
fallback create masked the miss.
Signed-off-by: Émile Ré <emile@probo.com>
Add the partial unique index that enforces one org ThirdParty per catalog
vendor per organization, on (organization_id, common_third_party_id)
where common_third_party_id is not null. Manually created vendors (null
common id) stay unconstrained.
Existing data may already hold duplicates left by the old auto-create
race, which would make the index build fail, so a preceding DO block
merges them first. It maps each duplicate onto the earliest-created
survivor and, driven by pg_constraint / pg_index introspection, repoints
every foreign key that references third_parties(id) onto the survivor,
removing link rows that would collide on a referencing table's unique
key before repointing, then deletes the extras.
The dedupe is destructive and introspection-driven; validate it against a
production dump before deploying.
Signed-off-by: Émile Ré <emile@probo.com>
With the tracker-mapping worker no longer materializing org third
parties, add the deliberate action that does: ThirdPartyService.Import
FromCommon seeds an org ThirdParty from a CommonThirdParty catalog entry
or returns the one the organization already imported, making it
idempotent on the (organization_id, common_third_party_id) pair.
On both the create and reuse paths it backfills tracker_patterns.third_
party_id for the organization's unlinked patterns whose catalog row
resolves to the same common third party, via the new TrackerPatterns.Link
ThirdPartyByCommonThirdPartyID. Patterns that previously surfaced the
catalog entry then surface the managed org vendor in the trackers UI and
the tracker-policy document. Only unlinked patterns are touched, so the
backfill is idempotent and picks up newly detected patterns on re-import.
End-to-end coverage (idempotency and pattern backfill) lands with the
GraphQL mutation in a following commit.
Signed-off-by: Émile Ré <emile@probo.com>
Drivers that cannot determine account status leave Active nil.
Rather than surfacing NULL in the database, treat an absent signal
as active (the account appeared in the source listing).
- Migration: NOT NULL DEFAULT TRUE on the active column
- Insert/upsert SQL: COALESCE(@active, TRUE) on write
- Fix new(true) in test (not valid Go; use &activeTrue)
Signed-off-by: Bryan Frimin <bryan@probo.com>
Track whether an account is active (enabled) or disabled at the
source system. The field is nullable so existing entries without
this data remain valid.
- DB migration adds active BOOLEAN column to access_entries
- Coredata read/write/upsert/filter wiring for the new column
- Review engine propagates Active from source accounts
- GraphQL schema exposes active on AccessEntry and AccessEntryFilter
- MCP spec, types, and resolvers expose active and fix missing
account_type filter that was wired in GraphQL but not MCP
- CLI list command adds --active filter flag and ACTIVE output column
- Console campaign detail table shows Active/Disabled status badge
- E2e and unit tests updated to cover the new field
Signed-off-by: Bryan Frimin <bryan@probo.com>
When IP geolocation does not resolve a country, or resolves one with no
known cookie-consent regulation (common on localhost and unmapped
regions), the banner previously fell back to OPT_OUT with no recorded
regulation. Apply GDPR (OPT_IN) as the safe default in that case so the
strictest consent model wins when origin is unknown.
To keep consent records auditable, stamp each one with a regulation
source of DETECTED (resolved from geolocation) or DEFAULT (fell back to
GDPR). The shared cookiebanner.ResolveRegulation helper centralizes the
decision for both the config and consent endpoints, and the new value is
exposed through GraphQL, MCP, the CLI, the n8n node, and the console
consent-records views.
Signed-off-by: Émile Ré <emile@probo.com>
A pre-fix bug in the cookie-banner SDK walked its own bundle frame
when computing a tracker's initiator, so cookies and storage written
by third parties, malware, or browser extensions were attributed to
cookie-banner.iife.js. The report upsert keeps the existing value on
re-detection (initiator_url = COALESCE(new, old)), so rows whose
corrected initiator is NULL would keep the stale bundle URL forever.
Null initiator_url and initiator_domain on detected_trackers rows that
point at the @probo/cookie-banner bundle. Genuine third-party rows
repopulate the correct initiator on the next detection.
Signed-off-by: Émile Ré <emile@probo.com>
Operators previously had no way to curate the global tracker catalog
beyond inspection and banner-scoped resets. Add three proboctl commands
backed by small coredata helpers:
- common-third-party upsert: create or update a vendor keyed by slug,
with partial-merge so an unset flag never blanks an existing column.
- common-tracker-pattern link/unlink: repoint catalog rows at a common
third party (re-arming enrichment and remapping the uncategorised org
trackers so the mapping worker re-resolves the vendor) or detach them.
Unlinking skips enrichment and remap since there is no new vendor.
- common-tracker-pattern set-description: write a description, mark the
row enriched, and backfill linked org patterns lacking one.
Signed-off-by: Émile Ré <emile@probo.com>
The reset-trackers operator command reset every uncategorised,
non-excluded pattern of a banner and printed only a single summary
line once the transaction committed, giving no feedback during long
rebuilds.
Add a --keyword flag that scopes both the glob decomposition and the
mapping reset to patterns whose pattern or display name contains the
substring. The match lives in a new TrackerPatternFilter.WithPatternKeyword
field so it runs in SQL and is shared by the glob load and the
ResetAndRequestMappingByCookieCategoryID update, keeping the two in
lockstep. The banner-wide pattern-analysis re-arm is left unscoped.
Thread an optional progress callback through ResetBannerTrackers so the
command streams per-phase updates (category load, per-glob decomposition,
mapping reset, analysis re-arm) as the work runs.
Signed-off-by: Émile Ré <emile@probo.com>
Replace the many-to-many junction table with a direct
parent_third_party_id foreign key on third_parties. Each
sub-third-party now belongs to exactly one parent, making
duplicates across parents independent entities.
Replace the firstLevel boolean with an integer level field
(1 = direct, 2+ = parent level + 1) to support arbitrary
nesting depth.
Remove the createThirdPartyThirdPartyMapping and
deleteThirdPartyThirdPartyMapping mutations, the CLI
link/unlink commands, and the corresponding MCP tools.
Creating a child third party now just requires passing
parentThirdPartyId on the existing createThirdParty mutation.
The frontend walks the parentThirdParty chain to build
display names like "Name (Ancestor1/Ancestor2)" and shows
clickable ancestor links on the detail page.
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>