# Project access API

> Read and change a project's public/private access mode and its owner over the integrator 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

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

### 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. |

```json
{
  "mode": "private",
  "ownerUserUlid": "01J...OWNER",
  "grantedCount": 2
}
```

## Change access state

`PATCH /api/v1/projects/{projectId}/access`

Accepts 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

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

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

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

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

`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.

```json
{
  "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

`POST /api/v1/projects/{projectId}/access/members`

```json
{ "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

`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.

## Group allowlist

A [user group](https://docs.probara.net/en/developers/api/user-groups/) 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

`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 `404`s on a public
project — a dormant assignment is a valid, visible state on any project mode.

```json
{
  "items": [{ "ulid": "01J...GROUP", "name": "QA Squad", "description": null, "userCount": 4 }],
  "page": 1,
  "pageSize": 50,
  "total": 1
}
```

### Assign a group

`POST /api/v1/projects/{projectId}/access/groups`

```json
{ "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

`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.

### 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](https://docs.probara.net/en/developers/api/user-groups/) `GET`s), 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](https://docs.probara.net/en/developers/api/user-groups/#list-assigned-projects) for the full
shape.

### 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

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

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

```bash
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

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.
