Address PR review feedback on the profile-state split: SAML sign-in now
activates a pending profile, deactivation counts owners against the
profile's own organization to close a last-owner bypass, the migration
leaves historical activated_at/deactivated_at NULL rather than
fabricating timestamps, and pending members are no longer rendered with
the deactivated (faded) styling.
Drop the single-value state filter in favor of the multi-value states
across the profile and signatory surfaces. Remove ProfileFilter.state
(only states[] remains) and convert the signatures profileState filter
to profileStates. Turn the console people filter, the CLI
"user list --state" flag, and the n8n listUsers and getAllSignatures
state inputs into multi-select controls, where an empty selection means
all states.
Signed-off-by: Émile Ré <emile@probo.com>
"Archive" was misleading for users: the action sets a profile to
DEACTIVATED while keeping the person in the organization. Rename it to
"deactivate" across the API, CLI, MCP, n8n, and console UI.
Consolidate the two overlapping operations into a single deactivateUser
backed by the fuller, guarded logic (SCIM guard, last-active-owner
guard, invitation expiry, signature cancellation, membership update,
webhook) and authorized via iam:membership-profile:deactivate. Remove
the archiveUser surface and the thin state-only deactivate path.
Signed-off-by: Émile Ré <emile@probo.com>
Replace the binary profile ACTIVE/INACTIVE model with PENDING, ACTIVE,
and DEACTIVATED so invited-but-not-yet-activated members remain
assignable to assets, data, and risks instead of being treated like
deactivated users.
Add activated_at/deactivated_at timestamps and Mark* lifecycle helpers,
and update every transition (create, invite/re-invite, activation,
archive, SCIM, SAML, sessions, compliance-portal grant) to the new
states. Expose a multi-state states[] filter across coredata, GraphQL,
MCP, and the console owner pickers, which now request ACTIVE and
PENDING members.
A migration renames the membership_state enum, classifies existing
inactive profiles as PENDING from recent invitation activity, and
backfills the new timestamp columns.
Signed-off-by: Émile Ré <emile@probo.com>
Route audit-log and SCIM-event exports through export_jobs with typed
arguments, an iam BuildAndUploadExport/SendExportEmail implementation,
and a concurrent export-job worker with stale recovery. Stream JSONL via
page.WalkAll into S3, and expose the request flow on console, connect,
MCP, and CLI.
Co-authored-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
Move MagicLinkHandler out of oidc_handler.go. Completing a
magic link for an existing identity now marks the address
verified, matching OIDC behavior.
Signed-off-by: Émile Ré <emile@probo.com>
Unverified password identities were able to open sessions after
signing out. Reject sign-in with EMAIL_NOT_VERIFIED and add a
resend-confirmation flow so users can complete verification.
Signed-off-by: Émile Ré <emile@probo.com>
Makes it practical to find people in larger orgs across GraphQL, MCP, CLI, and n8n, with page size raised to 100.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
Failed OIDC, magic-link, and SAML sign-ins sent users to /auth/error
without the post-login destination, so Sign in dropped OAuth flows
and deep links. Propagate a validated continue query through auth
error redirects, recover it from OIDC state when the IdP denies or
cancels login, and forward it from AuthErrorPage to /auth/login.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
All SAML assertion failures now share authentication_failed, so
the dedicated mapper and its tests are no longer needed.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
SAML refusals are shown as generic authentication_failed, so log
them at Error for investigation and drop the unused SAML error
code mapper.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Expected refusals already have a dedicated auth error page, so
warn logs just add noise. Keep Error logging for generic
authentication failures that still need investigation.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
TenantID is an [8]byte, so casting 1 does not compile. Use
NewTenantID like the other tests.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Specific SAML refusal reasons leak organization and account state.
Still redirect to the shared auth error page, but always use
authentication_failed while logging the real cause server-side.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Missing, expired, or reused OIDC state left users on a JSON error
or a generic failure. Map those cases to invalid_state so they get
a clear prompt to restart sign-in.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Browser auth callbacks still returned JSON or used one-off pages
for several refusal reasons. Route OIDC email verification,
magic-link, and SAML ACS failures through /auth/error with stable
error codes so users always see an explanation.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
A dedicated personal-account page does not scale as more refusal
reasons appear. Redirect the OIDC callback to /auth/error with an
error code query parameter so the frontend can map codes to clear
user-facing messages.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Personal Google and Microsoft accounts were rejected with a raw
JSON unauthorized response after the OIDC callback. Redirect to a
dedicated auth page that explains the enterprise-account
requirement, and check enterprise eligibility before xms_edov so
Microsoft consumer accounts get the same clear error.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Empty NameID values were stored as '' and occupied the
unique saml_subject index, causing duplicate-key failures
on later logins. Reject blank NameIDs during assertion
validation, return a clear error when a NameID is already
linked to another account, and stop returning internal
errors from the SAML consume endpoint.
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
Update console, visitor, MCP, and Slack API
surfaces so schemas and resolvers use the
Compliance Portal naming consistently.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Authorization-code exchange used ExternalClientID, which
is empty for registered clients and broke e2e token
grants. Pass the authenticated client's GID instead.
Also satisfy wsl_v5 blank-line rules, wire the delete
reference dialog through its mutation hook, and name the
filemanager logger.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Align console references and OAuth branding with the
compliance-page model, and fix certificate cache eviction,
portal OAuth handlers, and magic-link edge cases left after
the trust-center rename.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Host chmod cannot open 0700 dirs owned by UID 1000, so
`-f` never sees root_ca.crt. Chmod from inside the
container as root while waiting for the cert.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Drop the misleading trust alias for complianceportal/visitor
and rename the CustomDomain config field to Management so
service wiring matches the package it points at.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Drop the portal-login redirect, accept continue URLs for magic links,
brand emails from the OAuth client when present, and update e2e coverage.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Rewire the console and visitor resolvers onto the management and visitor
services with compliance-portal authorization. Rename the GraphQL and MCP
ComplianceExternalURL type to ComplianceCustomLink, expose trust center
profile fields, default and custom domains, public URL, and the managed
flag, and drop the profile fields from the organization surface.
Signed-off-by: Bryan Frimin <bryan@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>
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>
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>
Deleting a profile still referenced elsewhere (for example as an
asset owner) surfaced an internal error. PostgreSQL reports ON DELETE
RESTRICT blocks as SQLSTATE 23001, not 23503; map both in profile
delete and propagate ErrProfileInUse through removeUser as CONFLICT.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
The GraphQL endpoint built its gqlgen server with bare handler.New and
no limits, so a single request with thousands of aliased resolver calls
was parsed, validated, executed, and marshalled in full. Under load this
let an unauthenticated client drive excessive CPU and memory use against
POST /api/connect/v1/graphql and the console and trust endpoints, which
share the same constructor (GHSA-prh2-g8pv-m7p9).
Add configurable guards in the shared gqlutils.NewHandler: a parser
token limit rejects oversized queries at lex time before any execution,
a fixed complexity limit caps field-selection count, an LRU query cache
avoids repeated parsing, and field suggestions are disabled. The limits
flow from a new APIConfig.GraphQL section through server and api config
into all three GraphQL handlers, with PROBOD_API_GRAPHQL_* env vars and
Helm values exposed for per-environment tuning.
Defaults are sized with generous headroom over real traffic: the parser
token limit (15000) and complexity limit (2000) sit far above the
largest legitimate frontend query yet well below the proof-of-concept
flood, so normal usage is unaffected while floods are rejected cheaply.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Introduce BearerChallengeMiddleware on MCP, Console and Connect GraphQL, Files, and OAuth2 userinfo. Call sites record challenge intent in context via NoteUnauthenticated, NoteInvalidToken, and NoteInsufficientScope; the middleware applies resource_metadata, invalid_token, and insufficient_scope on WriteHeader.
OAuth2 access token middleware flags rejected Bearer tokens for invalid_token challenges. Add Authorizer.ScopesForAction for the scope auth-param.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Replace pkg/iam/scopeset with pkg/iam/oauth2scope.Registry, a shared
OAuth2 scope→action registry used by the authorizer, OAuth2 service,
and Connect API. Registration stays open until probod calls Freeze();
read paths (RegisteredScopes, Allows, ValidateScopes) panic before
that.
Drop the leaky APIScopes surface and AllowedAPIScopes on manual
access-token creation in favor of registry.ValidateScopes. Metadata,
protected-resource metadata, and CIMD scope lists are built from
RegisteredScopes() via helpers in pkg/iam/oauth2/scopes.go. Expose
oauth2ScopesSupported as an OAuth2Scope GraphQL scalar.
Signed-off-by: Ludovic Vielle <ludovic@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>
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>
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>
Map membership, profile, and inactive-user failures from
OpenOIDCChildSessionForOrganization to a generic 404 instead
of 500 so org-scoped OIDC callbacks do not reveal tenant
access details.
Signed-off-by: Bryan Frimin <bryan@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>
Trust GraphQL and MCP still exposed presigned URL strings for
trust-center logos while console and connect already serve stable
File.downloadUrl paths. Phase 1 migrates the seven public logo
fields on trust GraphQL and the trust-center file references on MCP
to the shared File type; trust GraphQL NDA stays on fileUrl for a
follow-up.
Trust resolvers load public files through filemanager and map them
with types.NewFile. The trust app Relay queries and components now
read logo.downloadUrl. MCP specification, resolvers, and helpers
are updated in sync, including NDA on MCP where callers already
have file access.
filemanager is split into focused files and its URL surface is
narrowed to GenerateFileURL(file) for stable app URLs and
GeneratePresignedURL for S3 redirects. GetPublicFile remains the
DB entry point when only a file ID is known.
Add trust and MCP e2e coverage for public logo download URLs.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>
Replace Organization.logoUrl and horizontalLogoUrl with nested File
objects whose downloadUrl points at /api/files/v1/public/{id}, matching
the Console migration.
Org logos are FileVisibilityPublic and served without HTTP auth, so
Connect File.downloadUrl is built eagerly in NewFile with no field-level
authorize. Logo loading moves to iam.OrganizationService.LogoFile and
HorizontalLogoFile; the old URL generators are removed.
Sync IAM Relay components and n8n organization operations. Add an e2e
test for Connect multipart logo upload and ExecuteConnectWithFile.
Signed-off-by: Ludovic Vielle <ludovic@probo.com>