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 access state
Section titled “Get access state”/api/v1/projects/{projectId}/accessResponse
Section titled “Response”200 with:
| Field | Notes |
|---|---|
mode | The project’s stored "public" or "private" access mode |
ownerUserUlid | The project owner’s ULID, or null when unassigned (“Unassigned”) |
grantedCount | Allowlist 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}Change access state
Section titled “Change access state”/api/v1/projects/{projectId}/accessAccepts either or both of:
| Field | Notes |
|---|---|
mode | "public" or "private" — toggles the access mode |
ownerUserUlid | ULID of the new owner, or null to unassign |
At least one field is required; an empty body returns 422 validation_failed.
Authorization
Section titled “Authorization”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.
Toggling to private
Section titled “Toggling to private”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).
Toggling to public
Section titled “Toggling to public”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.
Member allowlist
Section titled “Member allowlist”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.
List access members
Section titled “List access members”/api/v1/projects/{projectId}/access/membersPage-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.
Grant a member
Section titled “Grant a member”/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.
Revoke a member
Section titled “Revoke a member”/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.
Group allowlist
Section titled “Group allowlist”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.
List assigned groups
Section titled “List assigned groups”/api/v1/projects/{projectId}/access/groupsPage-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}Assign a group
Section titled “Assign a group”/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).
Unassign a group
Section titled “Unassign a group”/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.
The reverse view
Section titled “The reverse view”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.
No group-level revoke
Section titled “No group-level revoke”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.
Organization owner/administrator bypass
Section titled “Organization owner/administrator bypass”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.
Example
Section titled “Example”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"Known limitations
Section titled “Known limitations”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.