Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1b9d197
feat(schema): add project visibility + ProjectMember + migration
Jun 11, 2026
0716d5c
feat(authz): project-access core (canAccessProject/canManageProject/g…
Jun 11, 2026
cf19b6a
feat(projects): gate project.service + project-group.service by visib…
Jun 11, 2026
45733fd
feat(projects): cascade visibility gating to idea/proposal/document/t…
Jun 11, 2026
34fe7fd
feat(projects): cascade visibility gating to activity/notification/co…
Jun 11, 2026
93d7cdb
feat(projects): REST API visibility + members endpoints + nested-rout…
Jun 11, 2026
83edecf
feat(projects): MCP tools — visibility on create, member tools, proje…
Jun 11, 2026
15b8123
feat(projects): frontend visibility badge + settings modal member man…
Jun 11, 2026
1f159d1
test(projects): end-to-end privacy cascade integration test (real aut…
Jun 11, 2026
1429c27
docs+test(projects): visibility docs (MCP_TOOLS + skills), permission…
Jun 11, 2026
4500008
fix(projects): don't hide empty/no-accessible-project groups from the…
Jun 12, 2026
810d26e
feat(schema): add ProjectGroup visibility + ProjectGroupMember + migr…
Jun 12, 2026
82fd73d
feat(authz): two-level group inheritance — fold owned/member group pr…
Jun 12, 2026
8baaae6
feat(projects): group visibility service + REST members API + list-ga…
Jun 12, 2026
58ffc4e
feat(projects): group MCP tools (visibility on create + member tools)…
Jun 12, 2026
7406515
feat(projects): group visibility badge + manage-group members manager…
Jun 12, 2026
159145e
test+docs(projects): E2E group-inheritance test, group-tool docs, fix…
Jun 12, 2026
d618dc3
docs: update PR body for two-level group inheritance
Jun 12, 2026
10ea6ce
feat(authz): claim-on-manage helpers (access-gated) + canManageOrClai…
Jun 12, 2026
062c723
feat(projects): wire claim-on-manage into REST routes + MCP; fix unga…
Jun 12, 2026
35c1912
feat(projects): resolve member display names + member-add user search…
Jun 12, 2026
4c3e461
feat(projects): show manage controls for claimable legacy entities; m…
Jun 12, 2026
5fbb254
test+docs(projects): E2E claim-on-manage tests, claim/member-name doc…
Jun 12, 2026
06f7208
docs: update PR body for visibility UX fixes (claim-on-manage)
Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .claude/skills/cognito-user/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
name: cognito-user
description: Create, list, reset, or delete Chorus login accounts in the AWS Cognito user pool backing the chorus.tonyhh.people.aws.dev deployment. Use when the user asks to "open an account", "add a user", "create a login", reset someone's password, or remove a user.
license: AGPL-3.0
metadata:
author: chorus
version: "0.1.0"
category: operations
---

# Cognito User Management (chorus.tonyhh.people.aws.dev)

Manage human login accounts for the live Chorus deployment. Users log in via
Cognito OIDC; only `@amazon.com` emails are accepted (Chorus matches the email
domain to the "Amazon" company). Self-signup is disabled — an admin creates each
account here.

## Deployment constants

| Thing | Value |
|---|---|
| Region | `us-east-1` |
| Cognito User Pool | `us-east-1_w34zYdjVl` |
| App URL | https://chorus.tonyhh.people.aws.dev |
| Allowed email domain | `amazon.com` only |

## CRITICAL — AWS CLI v1 url-follow gotcha

This box runs aws-cli v1 with `cli_follow_urlparam` ON: any arg value starting
with `http(s)://` gets auto-fetched and replaced by the URL's CONTENT. Cognito
user commands don't take URLs so they're usually fine, but to be safe and
consistent, **always prefix Cognito CLI calls with the config override**:

```
AWS_CONFIG_FILE=/tmp/awscfg/config aws ...
```

If `/tmp/awscfg/config` is missing (fresh box), recreate it first:

```bash
mkdir -p /tmp/awscfg && printf '[default]\ncli_follow_urlparam = false\n' > /tmp/awscfg/config
```

## Create an account

1. Confirm the email ends in `@amazon.com`. If not, STOP and tell the user only
amazon.com emails can log in.
2. Generate a temporary password that meets Cognito complexity (upper+lower+
digit+symbol). Pattern used in this deployment: `Chorus@<6hex>1A`.
3. Run:

```bash
REGION=us-east-1
POOL=us-east-1_w34zYdjVl
EMAIL="<their>@amazon.com"
TMPPW="Chorus@$(openssl rand -hex 3)1A"
AWS_CONFIG_FILE=/tmp/awscfg/config aws cognito-idp admin-create-user \
--region "$REGION" --user-pool-id "$POOL" \
--username "$EMAIL" \
--user-attributes Name=email,Value="$EMAIL" Name=email_verified,Value=true \
--temporary-password "$TMPPW" \
--message-action SUPPRESS \
--query 'User.UserStatus' --output text
echo "TEMP_PW=$TMPPW"
```

4. Report the email + temporary password to the user, and tell them the login
flow: open the app URL → enter the email → redirected to Cognito → enter the
temporary password → **forced to set a new password** on first login → lands
in the "Amazon" workspace. The new Chorus User row is auto-provisioned on
first successful login (no extra step needed in Chorus itself).

Notes:
- `--message-action SUPPRESS` means Cognito does NOT email the user — you hand
them the temp password manually. To have Cognito email an invite instead,
drop that flag (requires SES email sending to be configured on the pool;
default Cognito email has a low daily cap).
- New users have status `FORCE_CHANGE_PASSWORD` until they complete first login.

## List accounts

```bash
AWS_CONFIG_FILE=/tmp/awscfg/config aws cognito-idp list-users \
--region us-east-1 --user-pool-id us-east-1_w34zYdjVl \
--query 'Users[].[Username,UserStatus]' --output table
```

## Reset someone's password

```bash
AWS_CONFIG_FILE=/tmp/awscfg/config aws cognito-idp admin-set-user-password \
--region us-east-1 --user-pool-id us-east-1_w34zYdjVl \
--username "<email>" --password "<NewTempPw1A@>" --no-permanent
```
`--no-permanent` forces a change on next login; use `--permanent` to set a final
password that won't require changing.

## Delete an account

```bash
AWS_CONFIG_FILE=/tmp/awscfg/config aws cognito-idp admin-delete-user \
--region us-east-1 --user-pool-id us-east-1_w34zYdjVl --username "<email>"
```
This removes the Cognito login. The corresponding Chorus User row (and their
authored data) remains in the app DB; remove it separately via the app if needed.

## Related

Full deployment details (resource IDs, ops access via SSM, the OIDC company
config) live in the project memory note `chorus-aws-deployment`.
121 changes: 121 additions & 0 deletions PR_BODY_project_visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Project Visibility (Private / Shared) + Two-Level Group Inheritance

> This branch contains **two stacked features**: (1) per-**project** visibility, and (2) per-**project-group** visibility that projects **inherit** via a dynamic union. They share the `project-access.ts` authz core and ship together. A mid-stream regression (empty groups vanishing from the list) was also fixed (commit `4500008`).

---

## Part 1 — Project Visibility (Private / Shared)

## Summary

Adds a second access dimension to projects on top of multi-tenancy. A project is now either:

- **`shared`** — visible to the whole company (the historical behavior), or
- **`private`** — visible only to its **owner** and an explicit **member list** (users *and* agents).

Membership — not the permission bitset — is what grants access to a private project. Holding `project:admin` does **not** bypass it; only the **super admin** platform role sees everything (for governance).

New projects default to **private** (owner = creating actor, who is auto-added as the first member). A data migration sets all **pre-existing** projects to **shared**, so no current work becomes inaccessible.

## Why

Previously every project was visible to everyone in the company (services scoped only by `companyUuid`). Teams asked for private workspaces that a subset of people/agents can collaborate in.

## How it works

A single authz module — `src/lib/authz/project-access.ts` — is the source of truth:

- `getAccessibleProjectUuids(auth)` → the set of project UUIDs the actor may see (or an `ALL` sentinel for super admin)
- `canAccessProject(auth, projectUuid)` → read **and** write gate
- `canManageProject(auth, projectUuid)` → owner-only gate (visibility / membership / delete)
- `applyProjectFilter(where, accessible)` → injects `projectUuid: { in: [...] }` into existing (company-scoped) queries

Access is enforced **across the whole cascade** — the project and all of its ideas, proposals, documents, tasks, activity, comments, notifications, and search results are filtered for non-members. Both **reads and writes** are gated (e.g. a non-member agent cannot claim/update a private task or post a comment on it).

## Surfaces changed

- **Schema**: `Project.visibility` / `ownerType` / `ownerUuid`, new `ProjectMember` table, migration with `shared` backfill.
- **Services**: project, project-group, idea, proposal, document, task, activity, notification, comment, search, assignment, idea-tracker — all gated.
- **REST API**: `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/[uuid]`, new `/api/projects/[uuid]/members` (GET/POST/DELETE, owner-only), and `canAccessProject` guards on every nested route. Leak rule: inaccessible → `404`, accessible-but-not-owner manage → `403`.
- **MCP tools**: `chorus_admin_create_project` gains `visibility` + `memberUuids`; new `chorus_list_project_members` (`project:read`), `chorus_admin_add_project_member` / `chorus_admin_remove_project_member` (`project:admin`); list/get project & group tools and every projectUuid-taking tool gated.
- **Frontend**: Lock badge on private projects; project settings modal gains a visibility toggle + owner-only members manager (shadcn-only, i18n en/zh, IME-safe).
- **Docs**: `docs/MCP_TOOLS.md` + both skill doc sets.

## Testing

- Unit tests for the authz core (full actor × visibility matrix, incl. `project:admin`-non-member denied and `projectUuids[]` header does **not** grant access).
- Read- and write-gating tests across every affected service.
- A dedicated **end-to-end privacy integration test** (`src/__tests__/integration/project-visibility.integration.test.ts`) that drives the real authz + services over an in-memory Prisma and asserts the full boundary: non-member deny (reads + writes), owner/member allow, super_admin all-access, `project:admin`-non-member deny, shared-project regression.
- Full gate green: `tsc` ✓, `pnpm test` (1889 pass / 1 skip) ✓, coverage **95.06% stmts / 87.94% branches / 95.95% funcs / 96.82% lines** (≥ thresholds) ✓, `pnpm build` ✓.

## Migration / rollout

The migration adds the columns (default `private`) **and** runs `UPDATE "Project" SET visibility='shared'` for all pre-existing rows, so production data stays fully visible. The standalone Docker entrypoint runs `prisma migrate deploy` automatically on container start.

## Known follow-ups (out of scope)

- Per-member roles (viewer/editor/admin) — currently a single `member` role.
- `project_group` entity **names** are still searchable in global search (group containers aren't visibility-gated); private-project *entities* never leak.
- `docs/design.pen` not updated in this environment (Pencil MCP tooling unavailable) — to refresh when design tooling is available.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---

## Part 2 — Two-Level Visibility (ProjectGroup → Project inheritance)

### Summary

Project **groups** gain the same `shared`/`private` + owner + member model, and a project's effective access becomes the **dynamic union** of its own accessors and its group's accessors.

- **Inheritance = dynamic union**: a project's accessors = (project owner + members) ∪ (its group's owner + members). Add someone to a private group → they instantly reach **every project in it** (and all cascaded entities). No snapshot; computed at query time.
- **"项目级 > 项目组" (project-level is authoritative)**: a project's own `visibility` flag wins. A `shared` project inside a `private` group is still company-wide; a **`private` project inside a `shared` group is still restricted** — a shared group never exposes its private projects. (Enforced by using *only owner/member* groups for the project-union, never shared groups.)
- New groups default **private** (creator = owner + first member); existing groups migrate to **shared**. A new project created with a `groupUuid` defaults to its group's visibility.
- Group management (visibility, members, update, delete) is **owner + super-admin only** — no `project:admin` bypass.

### Surfaces

- **Schema**: `ProjectGroup.visibility`/`ownerType`/`ownerUuid`, new `ProjectGroupMember` table, migration with `shared` backfill.
- **Authz core** (`project-access.ts`): `getAccessibleProjectUuids` + `canAccessProject` fold in owned/member groups; new `getAccessibleGroupUuids` / `canAccessGroup` / `canManageGroup`. Two **distinct** group-sets kept rigorously separate (project-union = owner/member only; group-visibility = shared∪owned∪member).
- **Service / REST**: group visibility + member CRUD, `listProjectGroups` gated by `canAccessGroup` (preserving the empty-group fix), new `/api/project-groups/[uuid]/members`, project inherits group visibility default.
- **MCP**: `chorus_admin_create_project_group` gains `visibility`/`memberUuids`; new `chorus_list_project_group_members` (`project:read`), `chorus_admin_add/remove_project_group_member` (`project:admin`); `update`/`delete` group tools now owner-gated.
- **Frontend**: Lock badge on private groups; manage-group dialog visibility toggle + owner-only members manager (i18n en/zh).
- **Docs**: MCP_TOOLS.md + both skill doc sets.

### Testing (whole branch)

- Authz unit matrix extended for group inheritance incl. **both cross-case invariants** (shared-in-private-group still company-wide; private-in-shared-group still restricted) and `project:admin`-non-member-denied.
- End-to-end integration test extended: a group member gains read+write across the group's private project + cascade purely via group membership; **dynamic revocation** (remove from group → access flips); non-member + `project:admin`-non-member denied.
- Full gate green: `tsc` ✓, **1954 tests pass / 1 skip** ✓, coverage **95.16% stmts / 88.5% branches / 96.03% funcs / 96.92% lines** (≥ thresholds) ✓, `pnpm build` ✓.

### Migration / rollout

Both migrations (`add_project_visibility`, `add_project_group_visibility`) run automatically via the Docker entrypoint and backfill existing rows to `shared`. Already deployed to the live standalone instance for validation.

### Known follow-ups (out of scope)

- Per-member roles (viewer/editor/admin) — single `member` role for both projects and groups.
- Project cannot NARROW/remove inherited group members (union only — by design).
- Nested groups (single level, unchanged).
- `docs/design.pen` not updated (Pencil MCP tooling unavailable in this environment).

---

## Part 3 — Visibility UX fixes (claim-on-manage, group DELETE gate, member UX)

Addresses three user-reported bugs, all rooted in **legacy null-owner entities** (the migration set pre-existing projects/groups to `shared` with no owner, so `isOwner` was false for everyone → manage controls hidden).

- **Claim-on-first-manage**: the first actor who can **access AND manage** an owner-less project/group (via any manage action — set visibility, add/remove member, update, delete) **claims ownership** and is seeded as a member. Strictly **access-gated** (a non-member of a *private* owner-less entity can never claim it — no privacy hole), **never reassigns** an existing owner, race-safe (guarded `updateMany where ownerUuid:null` + lost-race re-read), and super_admin manages without claiming.
- **Security fix**: `DELETE /api/project-groups/[uuid]` was **ungated** — added `canAccessGroup`(→404) then `claimOrCanManageGroup`(→403), matching PATCH.
- **Member UX**: member lists now resolve **display names** (`getActorName`) instead of raw UUIDs; the member-add search surfaces **human users** (and a default list on empty input) via a `forMembers` flag on `/api/mentionables` (the @mention autocomplete default is unchanged).
- **Dashboards** show manage controls for claimable legacy entities via a **pure** `canManageOrClaimable*` predicate (no write on a GET; the real claim happens on the manage action).

Pure read gates (`canManageProject/Group`) stay side-effect-free; all MCP mutating tools + REST manage routes use the claim-aware variant.

### Testing
- Authz unit matrix extended: access-gated claim (non-member of private owner-less → denied, no write), never-reassign, lost-race, super_admin no-claim, pure-predicate no-write.
- E2E integration: legacy null-owner project & group claimed by first accessible manager (owner set + member seeded), a different user then denied, private owner-less non-member denied (owner stays null), super_admin no-claim.
- Full gate green: `tsc` ✓, **1990 tests pass / 1 skip** ✓, coverage **95.23% stmts / 88.77% branches / 96.06% funcs / 96.97% lines** ✓, `pnpm build` ✓.

### Migration / rollout
No schema change (owner columns already exist). Pure logic/UX + the DELETE gate fix. Already deployed to the live standalone instance.
32 changes: 31 additions & 1 deletion docs/MCP_TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ The following table summarizes every permission-gated MCP tool. Each tool has ex
| `chorus_admin_update_project_group` | `project:write` |
| `chorus_admin_delete_project_group` | `project:write` |
| `chorus_admin_move_project_to_group` | `project:write` |
| `chorus_list_project_members` | `project:read` |
| `chorus_admin_add_project_member` | `project:admin` |
| `chorus_admin_remove_project_member` | `project:admin` |
| `chorus_list_project_group_members` | `project:read` |
| `chorus_admin_add_project_group_member` | `project:admin` |
| `chorus_admin_remove_project_group_member` | `project:admin` |
| `chorus_admin_approve_proposal` | `proposal:admin` |
| `chorus_admin_close_proposal` | `proposal:admin` |
| `chorus_admin_verify_task` | `task:admin` |
Expand All @@ -78,9 +84,33 @@ For agents that rely on the preset alone (no custom permissions), this is the re
| PM Agent | Public + Session + PM (`idea:write` + `proposal:write` + `document:write` + `task:write` + `project:write` tools — includes Developer's `task:write` tools and `project:write` project-management tools) |
| Admin Agent | Public + Session + PM + Developer + Admin (all 15 permissions) |

## Project Visibility (Private / Shared)

Every project is either **`shared`** (visible to the whole company — the historical default) or **`private`** (visible only to its owner and explicit members). Membership is the *only* way into a private project:

- **Access is by membership, not permission.** Holding `project:admin` does **not** let an agent see or touch a private project it is not a member of. The `X-Chorus-Project` / `X-Chorus-Project-Group` headers are convenience filters and likewise do **not** grant access.
- **Cascade.** A private project hides itself *and* all of its ideas, proposals, documents, tasks, activity, comments, notifications, and search hits from non-members. Every list is filtered to the caller's accessible set; every single-entity read or write on an inaccessible project is rejected as not-found.
- **Owner & members.** Members can be users *or* agents (AI+human parity). The owner (set to the creating actor) can manage visibility and membership.
- **Defaults.** New projects created via `chorus_admin_create_project` default to `private`. The creating agent becomes the owner and first member. Pass `visibility: "shared"` to opt into company-wide visibility, and `memberUuids` to seed additional members.
- **Super admin** retains full visibility for governance.

Member-management tools: `chorus_list_project_members` (`project:read`), `chorus_admin_add_project_member` and `chorus_admin_remove_project_member` (`project:admin`, owner-gated).

### Two-level visibility (ProjectGroup → Project inheritance)

Project **groups** carry the same `shared`/`private` + owner + member model, and a project **inherits** its group's accessors as a **dynamic union**:

- A project's effective accessors = (its own owner + members) **∪** (its group's owner + members). Adding someone to a private group instantly grants them access to **every project in that group** (and all those projects' cascaded entities) — no snapshot.
- **The project's own visibility flag stays authoritative** ("项目级 > 项目组"): a `shared` project inside a `private` group is still company-wide; a `private` project inside a `shared` group is still restricted. The group only *adds* accessors — a shared group never exposes its private projects to everyone.
- Group management (`chorus_admin_create_project_group` with `visibility`/`memberUuids`, `chorus_admin_add_project_group_member`, `chorus_admin_remove_project_group_member`, `chorus_admin_update_project_group`, `chorus_admin_delete_project_group`) is owner-gated; `chorus_list_project_group_members` requires `project:read`. New groups default to `private` (creating agent = owner + first member). A new project with a `groupUuid` defaults to its group's visibility unless `visibility` is passed explicitly.

### Claim-on-first-manage (legacy / owner-less entities)

Projects and groups that existed before visibility shipped (or were otherwise created without an owner) have **no owner**. The **first actor who can access and manage** such an entity — via any manage action (set visibility, add/remove member, update, delete) — **claims ownership** of it and is added as a member. This is access-gated: a non-member of a *private* owner-less entity can never claim it (so it opens no privacy hole), and an entity that already has an owner is never reassigned. Member-list tools (`chorus_list_project_members`, `chorus_list_project_group_members`) return a resolved display `name` per member alongside the UUID.

## Project Filtering

Agents can filter results by project(s) using HTTP headers during MCP connection. This is useful when an agent works on multiple projects and wants to focus on a specific subset.
Agents can filter results by project(s) using HTTP headers during MCP connection. This is useful when an agent works on multiple projects and wants to focus on a specific subset. Note: filtering narrows results *within* the caller's accessible (visibility-permitted) set — it never widens access to private projects the agent is not a member of.

### Available Headers

Expand Down
Loading
Loading