# API de acceso a proyectos

> Leer y cambiar el modo de acceso público/privado de un proyecto y su propietario en la API para integradores.

Cada proyecto almacena su propio **modo de acceso** — `public` o `private` — como una columna del propio
proyecto; ya no se infiere a partir de los datos de membresía. `public` (el modo por defecto con el que
empieza todo proyecto) significa que cualquier miembro activo de la organización puede alcanzarlo, salvo
una exclusión individual `revoked`. `private` significa que solo los miembros con una fila `granted` — más
el bypass de propietario/administrador de la organización — pueden alcanzarlo, y esa lista de acceso puede
legítimamente estar **vacía**: un proyecto privado con cero miembros otorgados sigue siendo privado, no
retrocede a público. Este recurso es **dedicado**: el estado de acceso nunca se agrega al objeto general
del proyecto que devuelve `GET /api/v1/projects/{projectId}` — esa forma permanece sin cambios.

`{projectId}` es el **código** del proyecto (por ejemplo `ACME`) en todo lo siguiente.

El **propietario** de un proyecto se asigna al crearlo: el usuario que lo crea queda como
propietario. Un proyecto creado con un **token de API** no tiene usuario a quien atribuirlo, así que
nace **sin asignar** (`ownerUserUlid` es `null`) y necesita un `PATCH` explícito para tener
propietario.

## Obtener el estado de acceso

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

### Respuesta

`200` con:

| Campo           | Notas                                                                                                                                                                               |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode`          | El modo de acceso `"public"` o `"private"` almacenado del proyecto                                                                                                                  |
| `ownerUserUlid` | El ULID del propietario del proyecto, o `null` cuando no está asignado ("Sin asignar")                                                                                              |
| `grantedCount`  | Tamaño de la lista de acceso. **No es la señal del modo** — un proyecto privado PUEDE reportar `grantedCount: 0`; nunca infieras el modo a partir de este número en ningún sentido. |

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

## Cambiar el estado de acceso

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

Acepta uno o ambos de:

| Campo           | Notas                                                         |
| --------------- | ------------------------------------------------------------- |
| `mode`          | `"public"` o `"private"` — alterna el modo de acceso          |
| `ownerUserUlid` | ULID del nuevo propietario, o `null` para dejarlo sin asignar |

Se requiere al menos un campo; un cuerpo vacío devuelve `422 validation_failed`.

### Autorización

Cambiar el estado de acceso es una **pregunta distinta** a la de leer un proyecto. Solo un propietario u
administrador de la organización, o el **propietario actual** del proyecto, pueden invocar este endpoint
con éxito — un miembro que solo tiene una fila `granted` (de lectura) en un proyecto privado **no** está
automáticamente autorizado a alternar el modo, y recibe `403 forbidden`.

Transferir la propiedad sigue la misma regla: quien llama debe ser propietario/administrador de la
organización o el propietario actual. El nuevo propietario debe ser un miembro activo de la organización.

### Alternar a privado

Cambiar un proyecto de público a privado crea exactamente dos filas `granted`: el **propietario** del
proyecto y el **usuario que realiza la acción** — una sola fila si son la misma persona, y solo la del
actor si el proyecto aún no tiene propietario. Cualquier otro miembro de la organización pierde el acceso
en su siguiente solicitud (salvo que su propio rol de organización tenga bypass de acceso a nivel de
proyecto — ver abajo).

### Alternar a público

Cambiar un proyecto de privado a público **descarta toda la lista de acceso**. Si el proyecto vuelve a
hacerse privado más tarde, empieza desde cero y vuelve a sembrar solo al propietario y al actor —
los accesos anteriores **no** se restauran.

## Lista de acceso de miembros

Una vez que un proyecto es `private`, se pueden agregar o quitar miembros individuales de su lista de
acceso sin cambiar nunca el modo del proyecto. Solo `PATCH /access { mode }` cambia el modo, en cualquier
dirección.

### Listar los miembros con acceso

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

Paginado (`?page=`/`?pageSize=`, ambos opcionales). Devuelve `200` con cada miembro **activo** de la
organización que tiene una fila `granted` para el proyecto o que lo alcanza mediante el bypass de rol
`owner`/`admin` de la organización. En un proyecto **público** devuelve una página vacía (`items: []`,
`total: 0`) — nunca responde `404`.

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

Cada elemento no lleva campo de procedencia de acceso: no puede indicar _por qué_ aparece un miembro (un
otorgamiento directo o el bypass de rol de organización). Combina esta respuesta con `GET /access`
(`grantedCount`) y la lista de miembros de la organización para construir una vista de auditoría o
cumplimiento que necesite esa distinción.

### Otorgar acceso a un miembro

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

```json
{ "userUlid": "01J...MEMBER" }
```

Devuelve `201` con el miembro otorgado (misma forma que un elemento de la lista). Requiere el permiso
de gestión de proyectos (que tienen owner/admin), o el propietario actual del proyecto — tener una
fila `granted` por sí sola **no** autoriza a otorgar acceso a otros. Se rechaza con `409 conflict` en un proyecto público, y con
`404 not_found` para un miembro que ya no está activo (se fue de la organización). Volver a otorgar
acceso a un miembro previamente revocado sobrescribe esa exclusión voluntaria.

### Revocar el acceso de un miembro

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

Devuelve `204` si tiene éxito. Misma regla de autorización que otorgar acceso. Se rechaza con
`409 conflict` en un proyecto público. Revocar la **última** fila `granted` restante ahora tiene éxito: el
proyecto permanece `private` con la lista de acceso vacía — no se publica. Publicar un proyecto conserva
exactamente un punto de entrada, `PATCH /access { mode: "public" }`. Revocar nunca escribe una fila
`revoked`; elimina la fila `granted` directamente.

## Lista de acceso de grupos

Un [grupo de usuarios](https://docs.probara.net/es/developers/api/user-groups/) también puede asignarse a la lista de
acceso de un proyecto, otorgando acceso a todos los miembros actuales y futuros del grupo en una
sola operación — un mecanismo separado y de grano más grueso, junto a la lista de acceso
individual de arriba.

### Listar los grupos asignados

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

Basado en páginas (`?page=`/`?pageSize=`, ambos opcionales). Refleja el plano, la protección y la
forma de los elementos de `/access/members`. A diferencia de la subcolección de miembros, este
endpoint de listado nunca da `404` en un proyecto público — una asignación dormida es un estado
válido y visible en cualquier modo del proyecto.

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

### Asignar un grupo

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

```json
{ "userGroupUlid": "01J...GROUP" }
```

Devuelve `201` con el grupo asignado (misma forma que un elemento del listado). Misma regla de
autorización que la familia individual: **`owner`**/**`admin`** de la organización, o el
propietario actual del proyecto. A diferencia de otorgar acceso a un miembro, esto **tiene éxito
en un proyecto público** — `access_mode` es un dato almacenado, nunca inferido de una asignación,
así que el otorgamiento queda dormido hasta que el proyecto se privatiza y sobrevive a cualquier
cambio de modo posterior en cualquier dirección. Volver a asignar un grupo ya asignado es una
operación idempotente sin efecto (`201` de nuevo, sin duplicar la fila).

### Desasignar un grupo

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

Devuelve `204` si tiene éxito. Misma regla de autorización que asignar. Nunca `409`, ni siquiera
para el **último** grupo restante — desasignar el último grupo no vuelve a publicar el proyecto;
publicar conserva exactamente un punto de entrada, `PATCH /access { mode: "public" }`. Devuelve
`404 not_found` para un ULID de grupo desconocido, un grupo de otra organización, o un grupo que
nunca fue asignado.

### La vista inversa

`GET /api/v1/groups/{userGroupUlid}/projects` lista los proyectos a los que está asignado un
grupo dado — el reflejo centrado en el grupo del listado de arriba. Está abierto a lectura para
cualquier miembro de la organización (como los demás `GET` de
[grupos de usuarios](https://docs.probara.net/es/developers/api/user-groups/)), así que, a diferencia del `projectCount`
del propio grupo, filtra cualquier proyecto que el miembro que llama no pueda descubrir de otra
forma: un proyecto privado al que el grupo está asignado se omite silenciosamente para ese
llamante, mientras que un **`owner`**/**`admin`** de la organización ve todas las asignaciones.
Ver [Listar proyectos asignados](https://docs.probara.net/es/developers/api/user-groups/#listar-proyectos-asignados) para
la forma completa.

### Sin revocación a nivel de grupo

No existe un estado `revoked` a nivel de grupo. La fila `revoked` propia de la lista de acceso
individual de un miembro — establecida al eliminarlo o mediante una revocación explícita —
siempre prevalece sobre un otorgamiento por grupo al que ese miembro pudiera acceder de otra
forma: un grupo otorga acceso, nunca anula el veto individual.

### Bypass de propietario/administrador de la organización

Quien llama con rol de organización `owner` o `admin` siempre alcanza todos los proyectos de esa
organización, sean privados o no, y siempre está autorizado a alternar el modo o transferir la propiedad.

### La aplicación sigue a los datos, no a la forma de la URL

El control de acceso no se limita a la familia de rutas `/projects/{projectId}/*`. Alcanzar el contenido de
un proyecto de forma transitiva a través de un ULID de entidad — un caso de prueba, una ejecución, un
defecto, un plan, un hito, un entorno, una configuración o un comentario — produce la misma decisión de
acceso que dirigirse al proyecto directamente. Un miembro sin acceso otorgado en un proyecto privado recibe
`403` por cualquiera de las dos vías; un miembro con acceso otorgado, y un `owner`/`admin` de la
organización, alcanzan ambas de la misma forma.

### Ejemplo

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

## Limitaciones conocidas

Son huecos intencionales y documentados, no descuidos:

- **Los tokens de API no se evalúan contra la lista de acceso.** Una solicitud autenticada con un token
  Bearer no lleva identidad por usuario para este propósito y sigue alcanzando todos los proyectos de su
  organización, sean privados o no — "privado" aplica solo a quienes llaman como humanos (sesión/OAuth).
- **La interfaz web cubre el modo, el propietario y los miembros individuales.** La pestaña Control de
  acceso en la configuración del proyecto muestra el modo almacenado, permite a quien esté autorizado
  alternar público/privado, transferir la propiedad, y otorgar/revocar miembros individuales desde la
  lista de miembros individuales. **Los grupos todavía no están disponibles** — la pestaña Grupos es un
  diseño terminado sin funcionalidad; el acceso por grupos queda diferido a una versión futura.
