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>
Use the Connect schema's profile connection in the e2e regression so the test can find the sole owner's membership. Adjust whitespace around the resolver error path to satisfy Go lint.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Return a conflict when membership role updates would demote the final active owner in an organization.
Add an end-to-end regression that verifies the mutation fails and leaves the owner role intact.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
GraphQL profile ordering rejected EMAIL_ADDRESS because ProfileOrderField
did not expose this enum value in connect and console schemas.
Add EMAIL_ADDRESS to MembershipProfileOrderField and its validation list
so order input coercion accepts the value consistently. Extend
MembershipProfile cursor key encoding to support email ordering and avoid
runtime panics during pagination.
Update the MCP profile order enum to keep API surface definitions aligned
with the same ordering capability.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Expose viewer.invitingOrganizations: [Organization!]! returning the
organizations that have a live pending invitation directed at the
current identity (accepted_at IS NULL AND expires_at > NOW()). The
list is rendered under a "Pending invitations" section on the
memberships page and in the organization selector dropdown, so a user
already signed in with an existing identity can see which
organizations have invited them without having to dig through their
inbox.
The new field is gated by iam:invitation:list against the viewer's
own identity, so it does not loosen authorization on Organization
elsewhere. E2E coverage validates the live-pending case, the
no-invitation and post-accept cases, and a multi-org scenario.
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
Add a dedicated SCIMBridge syncError field to the Connect GraphQL API
and populate it from the core bridge model.
Update the connector cards to read bridge state and syncError directly
from the bridge object instead of inferring failures from event
history.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Change ArchiveUser to receive an explicit coredata scope from callers\ninstead of rebuilding scope from organization ID inside the service.\n\nWire connect and MCP resolvers to pass the authorized scope through to\nArchiveUser.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Drop the additional RemoveUser dependency-error handling that was added\noutside this PR scope.\n\nKeep the separate remove and archive operations, but let delete failures\npropagate through existing wrapped errors without the custom helper and\nreferenced-records error type.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Restore RemoveUser as a hard delete operation and surface dependency\nconflicts with a dedicated IAM error.\n\nAdd a new ArchiveUser flow that deactivates profiles while keeping the\nmember in the organization, then expose both actions across Connect, MCP,\nCLI, n8n, console UI, and e2e coverage.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Align user-removal UX and API surface semantics with the new archive\nbehavior for manually managed users.\n\nFrontend copy and actions now use archive wording, and list rows are\nrefetched after the mutation so archived users reappear as inactive.\n\nMCP removeUser now documents and returns archived_user_id, n8n labels\nand response mapping now use archive semantics, and the CLI gains a\nuser archive command backed by the same mutation.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Switch remove-user behavior for manually managed profiles from hard\ndelete to archival by deactivating the profile. This matches the\nrequested SCIM-like lifecycle while avoiding dependency errors for\nlinked records such as signatures and assets.\n\nThe remove flow now updates profile state to INACTIVE, updates\nmembership timestamps, and emits a user-updated webhook event instead of\ndelete events. E2E coverage now asserts that remove keeps the profile\nand marks it inactive.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Map remove-user foreign key failures to a dedicated IAM conflict error so\nconsole users no longer receive a generic internal server error when a\nprofile is still referenced by signed documents, tasks, assets, or\nother records.\n\nThe service now detects wrapped dependency errors across both profile\nand membership deletes, and resolvers explicitly map the new error type\nto conflict responses. Added unit tests cover dependency detection and\nthe user-facing error message.
Signed-off-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
Add authz.NewBatchAuthorizeFunc — the batch counterpart to the
existing AuthorizeFunc — together with WithBatchAttr,
WithBatchSkipAssumptionCheck, and WithBatchDryRun options. It maps the
new batch errors (mixed organization, empty batch, unsupported
resource type) to GraphQL Invalid responses, and reuses the existing
mappings for ErrAssumptionRequired / ErrInsufficientPermissions /
ErrResourceNotFound.
Plumb the new function into the Connect and Console resolvers and add
Resolver.AuthorizeBatch to the MCP resolver with equivalent error
mapping for tool callers.
Signed-off-by: Bryan Frimin <bryan@probo.com>
Deleting a membership profile referenced by other tables (owner,
approver, assignee, etc.) surfaced as a generic Internal error.
Detect the Postgres FK violation (23503) in the coredata Delete,
return ErrResourceInUse, and map it to CONFLICT in the GraphQL
and MCP resolvers so the client sees an actionable error.
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
Apply five style rules: convert iota string enums to typed
string constants, replace errors.As with errors.AsType,
merge three-group imports into two groups, fix multiline
parameter/argument formatting, and replace fmt.Sprintf URL
construction with net/url.
Signed-off-by: Émile Ré <emile@probo.com>
Microsoft 365's native SCIM endpoint is unreliable, so mirror the
Google Workspace bridge over Microsoft Graph: a new MICROSOFT_365
OAuth2 connector, a SCIM bridge provider listing /v1.0/users with
$select pagination, and an access review driver that derives admin
status from /directoryRoles members. Refactor the bridge runner to
share OAuth2 plumbing across providers and surface the new bridge
type, scopes, UI card, and bootstrap env wiring.
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
The go-sdk v1.6.0 release no longer applies a default
CrossOriginProtection when the field is nil in StreamableHTTPOptions,
silently removing Origin header verification. Wrap the streamable
handler with http.NewCrossOriginProtection().Handler(...) (the
recommended replacement, since the SDK field is deprecated).
Also regenerate gqlgen resolvers to track v0.17.90.
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
RFC 7662 lets clients introspect any OAuth2 token, but the endpoint
only resolved access tokens. Look up refresh tokens too, honor the
optional token_type_hint to drive lookup order with a fallback to the
other table, and report revoked or expired refresh tokens as inactive.
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Session validity was decoupled from credential rotation: changing
a password (logged-in flow) or completing a forgot-password reset
left every existing iam_sessions row valid until its idle TTL.
A user who saw their account compromised on another device had
no way to actually evict that device by rotating the password.
Inside the same DB transaction as the password update, expire the
identity's other active sessions:
- ChangePassword keeps the caller's current session and revokes
every other session for the identity, so the user is not
logged out of the browser they just used.
- ResetPassword has no caller session (the user is anonymous,
authenticated only by a stateless token) and revokes all of
the identity's active sessions.
The session middleware already rejects rows with expire_reason
set, so revoked sessions are kicked out on the next request
without any middleware change.
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
The SCIM event handler read the client address from
r.RemoteAddr only, so when probod runs behind an HTTP
load balancer every event row recorded the load
balancer's internal IP. Switch to clientip.Extract,
which honors the Forwarded / X-Forwarded-For headers
already validated by trustedproxy middleware.
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Add the profile state attribute (ACTIVE/INACTIVE) to the MCP
Profile schema so listUsers and getUser tools expose it, and
add a state filter to listUsers.
Rename excludeContractEnded to contractEnded across the entire
stack (MCP, GraphQL, CLI, frontend). The new boolean is two-way:
true returns only users with ended contracts, false returns only
users with active or no contract, and null returns all.
Signed-off-by: Émile Ré <emile@getprobo.com>
Implement a full OAuth2 2.0 and OpenID Connect 1.0 authorization
server with support for authorization code flow (with PKCE),
refresh token rotation, device authorization grant, dynamic
client registration, token introspection, and token revocation.
Includes database schema, coredata layer, service logic, HTTP
handlers, OIDC discovery endpoint, and JWKS publishing.
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Move OIDCProviderInfo type into base.graphql alongside its query field
in both connect and trust APIs, removing orphan oidc.graphql files.
Split connect profile.graphql into three domain files: profile (with
user mutations), membership, and invitation.
Signed-off-by: Émile Ré <emile@getprobo.com>
Change gqlgen filename_template from {name}.resolvers.go to
{name}_resolvers.go across all three APIs for consistent Go naming.
Signed-off-by: Émile Ré <emile@getprobo.com>