---
id: w6/m2/t003
title: REST GET /v1/owners/{ownerId}/members (Render-shaped, roles mapped from FGA)
worker: worker6
status: done
estimate: 25m
depends_on: [w6/m2/t002]
---

## Objective

The read half of workspace membership at Render's path: `GET /v1/owners/{ownerId}/members` lists the users belonging to the workspace, with each member's role read from the OpenFGA tuples / `tenant_members` rows, shaped per t001's pinned schema.

## Context

- Membership data: `tenant_members` + FGA relations (`deploy/gitops/authz/model.fga` already maps Render's role names).
- w4/m12 owns membership **writes** (invite/change/remove) and its own REST/GraphQL surface (its t003) — coordinate: this read endpoint should be the one w4/m12 extends, not a parallel one. If w4/m12 lands first, this task becomes a parity check of its endpoint at this path.
- Identity attributes (email, name) come from Kratos (`BEX_KRATOS_URL`).

## Steps

1. Core: `ListWorkspaceMembers(tenant)` joining membership rows with Kratos identity lookups.
2. REST handler at the Render path; member object per pinned schema (role naming matches Render's).
3. Authz: any member can list members (mirror Render; confirm in t001 capture if documented).

## Files

- `lego/backend/internal/api/`
- `lego/backend/internal/core/`

## Acceptance criteria

- [x] Members listed with Render's role names and member-object shape
- [x] Non-member caller gets 403/404
- [x] No duplicate membership-read path exists if w4/m12 has landed (one core verb serves both) — w4/m12 is still `todo`, unblocked; `workspaces.Service.ListMembers` is the one path, meant to be the base w4/m12 extends

## Out of scope

- Member mutations (w4/m12).
