Skip to content

Project access API

Every project stores its own access mode — public or private — as a column on the project itself; it is no longer inferred from membership data. public (the default every project starts in) means every active organization member can reach it, subject to an individual revoked opt-out. private means only members holding a granted row — plus the organization owner/admin bypass — can reach it, and that allowlist may legitimately be empty: a private project with zero granted members stays private, it does not fall back to public. This resource is dedicated: access state is never added to the general project object returned by GET /api/v1/projects/{projectId} — that shape stays unchanged.

{projectId} is the project code (for example ACME) everywhere below.

A project’s owner is set when the project is created: the creating user becomes the owner. A project created with an API token has no user to attribute, so it starts Unassigned (ownerUserUlid is null) and needs an explicit PATCH below to get an owner.

GET/api/v1/projects/{projectId}/access

200 with:

FieldNotes
modeThe project’s stored "public" or "private" access mode
ownerUserUlidThe project owner’s ULID, or null when unassigned (“Unassigned”)
grantedCountAllowlist size. Not the mode signal — a private project MAY report grantedCount: 0; never infer the mode from this count either way.
{
"mode": "private",
"ownerUserUlid": "01J...OWNER",
"grantedCount": 2
}
PATCH/api/v1/projects/{projectId}/access

Accepts either or both of:

FieldNotes
mode"public" or "private" — toggles the access mode
ownerUserUlidULID of the new owner, or null to unassign

At least one field is required; an empty body returns 422 validation_failed.

Changing access state is a different question from reading a project. Only an organization owner/administrator, or the project’s current owner, may call this endpoint successfully — a member who merely holds a granted (read) row on a private project is not automatically authorized to toggle it, and receives 403 forbidden.

Transferring ownership follows the same rule: the caller must be an organization owner/administrator or the current owner. The new owner must be an active member of the organization.

Switching a project from public to private creates exactly two granted rows: the project’s owner and the acting user — one row if they are the same person, and just the actor’s row if the project has no owner yet. Every other organization member loses access on their next request (unless their own organization role bypasses project-level access — see below).

Switching a project from private to public discards the entire allowlist. If the project is later made private again, it starts from zero and re-seeds only the owner and the acting user — earlier grants are not restored.

Once a project is private, individual members can be added to or removed from its allowlist without ever changing the project’s mode. Only PATCH /access { mode } changes the mode, in either direction.

GET/api/v1/projects/{projectId}/access/members

Page-based (?page=/?pageSize=, both optional). Returns 200 with every active organization member who either holds a granted row for the project or reaches it through the organization owner/admin role bypass. On a public project this returns an empty page (items: [], total: 0) — it never 404s.

{
"items": [
{
"userUlid": "01J...MEMBER",
"email": "ada@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"avatarKey": null,
"avatarVersion": 0,
"role": "member",
"positionTitle": "QA Lead"
}
],
"page": 1,
"pageSize": 50,
"total": 1
}

Each item carries no access-provenance field: it cannot tell you why a member is listed (a direct grant vs. the organization role bypass). Combine this response with GET /access (grantedCount) and the organization members list to build an audit or compliance view that needs that distinction.

POST/api/v1/projects/{projectId}/access/members
{ "userUlid": "01J...MEMBER" }

Returns 201 with the granted member (same shape as a list item). Requires the project management permission (held by owner/admin), or the project’s current owner — holding a granted row alone does not authorize granting others. Rejected with 409 conflict on a public project, and with 404 not_found for a departed (no-longer-active) member. Re-granting a member who was previously revoked overwrites that opt-out.

DELETE/api/v1/projects/{projectId}/access/members/{userUlid}

Returns 204 on success. Same authorization rule as granting. Rejected with 409 conflict on a public project. Revoking the last remaining granted row now succeeds: the project stays private with an emptied allowlist — it is not published. Publishing a project keeps exactly one entry point, PATCH /access { mode: "public" }. Revoking never writes a revoked row; it deletes the granted row outright.

A user group can also be assigned to a project’s allowlist, granting access to every current and future member of the group in a single operation — a separate, coarser-grained mechanism alongside the individual allowlist above.

GET/api/v1/projects/{projectId}/access/groups

Page-based (?page=/?pageSize=, both optional). Mirrors /access/members’s plane, gate, and item shape. Unlike the member sub-collection, this list endpoint never 404s on a public project — a dormant assignment is a valid, visible state on any project mode.

{
"items": [{ "ulid": "01J...GROUP", "name": "QA Squad", "description": null, "userCount": 4 }],
"page": 1,
"pageSize": 50,
"total": 1
}
POST/api/v1/projects/{projectId}/access/groups
{ "userGroupUlid": "01J...GROUP" }

Returns 201 with the assigned group (same shape as a list item). Same authorization rule as the individual family: organization owner/admin, or the project’s current owner. Unlike granting a member, this succeeds on a public project — access_mode is a stored fact, never inferred from an assignment, so the grant stays dormant until the project is privatized and survives every later mode toggle in either direction. Re-assigning an already-assigned group is an idempotent no-op (201 again, no duplicate row).

DELETE/api/v1/projects/{projectId}/access/groups/{userGroupUlid}

Returns 204 on success. Same authorization rule as assigning. Never 409, even for the last remaining group — unassigning the last group does not republish the project; publishing keeps exactly one entry point, PATCH /access { mode: "public" }. Returns 404 not_found for an unknown group ULID, a group from another organization, or a group that was never assigned.

GET /api/v1/groups/{userGroupUlid}/projects lists the projects a given group is assigned to — the group-centric mirror of the list above. It is read-open to any organization member (like the other user group GETs), so unlike projectCount on the group itself, it filters out any project the calling member cannot otherwise discover: a private project the group is assigned to is silently omitted for that caller, while an organization owner/admin sees every assignment. See List assigned projects for the full shape.

There is no group-level revoked state. The individual allowlist’s own revoked row on a member — set by removing them or by an explicit revoke — always outranks a group grant that member might otherwise reach through: a group provides access, never an override of the individual veto.

A caller whose organization role is owner or admin always reaches every project of that organization, private or not, and is always authorized to toggle mode or transfer ownership.

Enforcement follows the data, not the URL shape

Section titled “Enforcement follows the data, not the URL shape”

Access control is not limited to the /projects/{projectId}/* route family. Reaching a project’s contents transitively through an entity ULID — a test case, run, defect, plan, milestone, environment, configuration, or comment — produces the same access decision as addressing the project directly. A non-granted member of a private project is denied 403 either way; a granted member, and an organization owner/admin, reach both the same way.

Terminal window
curl -sS -X PATCH \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"mode":"private"}' \
"https://app.probara.net/api/v1/projects/ACME/access"

These are intentional, documented gaps — not oversights:

  • API tokens are not evaluated against the allowlist. A request authenticated with a Bearer API token carries no per-user identity for this purpose and keeps reaching every project of its organization, private or not — “private” applies to human (session/OAuth) callers only.
  • The web UI covers the mode toggle, owner, and individual members. The Control access tab in project settings shows the stored mode, lets an authorized caller toggle public/private, transfer ownership, and grant/revoke individual members from the Individual members list. Groups are not available yet — the Groups tab is a finished design with no functionality; group-based access is deferred to a future release.