groups: admin-managed groups, sharing a note with one, and membership in the feed

/api/groups (admin) creates, renames and deletes groups and adds or removes
members. The member directory lists every group, and a share may name a
group_id instead of a user_id; a note's shares answer with `member` or `group`.

A note shared with a group reaches whoever is in it now, so membership is what
the feed follows: joining grants each of the group's notes to the new member's
devices, and leaving (or the group being deleted) revokes them unless a direct
share or another group still reaches that person. recipients() never counts the
note's own owner, who may sit in a group it is shared with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 18:25:16 -04:00
co-authored by Claude Opus 5.5
parent 2e2714a50b
commit 5f3cfe8bb7
6 changed files with 358 additions and 34 deletions
+4 -2
View File
@@ -221,8 +221,10 @@ labels are personal, and a recipient files nothing under the owner's tags.
Granting or changing a share moves the note to a new revision (without touching Granting or changing a share moves the note to a new revision (without touching
`updated_at`, so last-write-wins is unaffected), which is how it passes a `updated_at`, so last-write-wins is unaffected), which is how it passes a
recipient's cursor. Ending a share — or the owner purging the note — adds the note recipient's cursor. Joining a group a note is shared with does the same for the
to the recipient's `revoked` list: new member (#5177). Ending a share — or the owner purging the note, the person
leaving the group, or the admin deleting it — adds the note to the `revoked` list of
each person who can no longer see it:
```json ```json
{ "notes": [ ... ], "labels": [ ... ], "revoked": ["<note id>", ...], { "notes": [ ... ], "labels": [ ... ], "revoked": ["<note id>", ...],
+2
View File
@@ -14,6 +14,7 @@ from quart.sessions import SecureCookieSessionInterface
from .accounts_api import bp as accounts_bp from .accounts_api import bp as accounts_bp
from .auth import bp as auth_bp from .auth import bp as auth_bp
from .client_dist import advertisement as client_advertisement, bp as client_bp from .client_dist import advertisement as client_advertisement, bp as client_bp
from .groups_api import bp as groups_bp
from .config import Config from .config import Config
from .db import session_scope from .db import session_scope
from .invites_api import bp as invites_bp from .invites_api import bp as invites_bp
@@ -96,6 +97,7 @@ def create_app() -> Quart:
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.register_blueprint(invites_bp) app.register_blueprint(invites_bp)
app.register_blueprint(accounts_bp) app.register_blueprint(accounts_bp)
app.register_blueprint(groups_bp)
app.register_blueprint(shares_bp) app.register_blueprint(shares_bp)
app.register_blueprint(sync_bp) app.register_blueprint(sync_bp)
app.register_blueprint(saved_filters_bp) app.register_blueprint(saved_filters_bp)
+166
View File
@@ -0,0 +1,166 @@
"""Admin routes over groups: named sets of people a note can be shared with (#5177).
A group is the instance's, not anyone's: the admin makes it and says who is in it,
and every member of the instance can share a note with it. Sharing with a group
reaches whoever is in it at the time, so adding or removing someone is what changes
their access, and `share_sync` tells their devices.
"""
from __future__ import annotations
import uuid
from quart import Blueprint, jsonify, request
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert
from .auth import require_admin
from .db import session_scope
from .models.group import Group, GroupMember
from .models.user import User
from .responses import json_error, not_found, parse_uuid
from .share_sync import bump_note, group_notes, joined_group, left_group, recipients, revoke
bp = Blueprint("groups", __name__, url_prefix="/api/groups")
NAME_CAP = 80
def _name(data: dict) -> str | None:
name = (data.get("name") or "").strip() if isinstance(data.get("name"), str) else ""
return name[:NAME_CAP] or None
async def _name_taken(db, name: str, but: uuid.UUID | None = None) -> bool:
stmt = select(Group.id).where(func.lower(Group.name) == name.lower())
if but is not None:
stmt = stmt.where(Group.id != but)
return await db.scalar(stmt) is not None
async def _serialize_groups(db, groups: list[Group]) -> list[dict]:
rows = (
await db.execute(
select(GroupMember.group_id, User)
.join(User, User.id == GroupMember.user_id)
.where(GroupMember.group_id.in_([gr.id for gr in groups]))
.order_by(User.display_name, User.email)
)
).all() if groups else []
members: dict = {}
for group_id, user in rows:
members.setdefault(group_id, []).append(
{"id": str(user.id), "display_name": user.display_name, "email": user.email}
)
return [{"id": str(gr.id), "name": gr.name, "members": members.get(gr.id, [])} for gr in groups]
async def _answer(db, group: Group, status: int = 200):
return jsonify((await _serialize_groups(db, [group]))[0]), status
@bp.get("")
@require_admin
async def list_groups():
async with session_scope() as db:
groups = (await db.scalars(select(Group).order_by(func.lower(Group.name)))).all()
return jsonify({"groups": await _serialize_groups(db, list(groups))})
@bp.post("")
@require_admin
async def create_group():
name = _name(await request.get_json(silent=True) or {})
if name is None:
return json_error("give the group a name", 400)
async with session_scope() as db:
if await _name_taken(db, name):
return json_error("there is already a group with that name", 409)
group = Group(name=name)
db.add(group)
await db.commit()
return await _answer(db, group, 201)
@bp.patch("/<group_id>")
@require_admin
async def rename_group(group_id: str):
name = _name(await request.get_json(silent=True) or {})
if name is None:
return json_error("give the group a name", 400)
gid = parse_uuid(group_id)
async with session_scope() as db:
group = await db.get(Group, gid) if gid else None
if group is None:
return not_found()
if await _name_taken(db, name, but=group.id):
return json_error("there is already a group with that name", 409)
group.name = name
await db.commit()
return await _answer(db, group)
@bp.delete("/<group_id>")
@require_admin
async def delete_group(group_id: str):
"""Delete the group and every share made to it. Whoever could see a note only
through it loses the note, and their devices are told."""
gid = parse_uuid(group_id)
async with session_scope() as db:
group = await db.get(Group, gid) if gid else None
if group is None:
return not_found()
note_ids = await group_notes(db, group.id)
before = {nid: await recipients(db, nid) for nid in note_ids}
await db.delete(group) # members and shares go with it (ON DELETE CASCADE)
await db.flush()
for nid in note_ids:
await revoke(db, nid, before[nid] - await recipients(db, nid))
# The owner's devices learn whether the note is still shared at all.
await bump_note(db, nid)
await db.commit()
return jsonify({"ok": True})
@bp.post("/<group_id>/members")
@require_admin
async def add_member(group_id: str):
data = await request.get_json(silent=True) or {}
gid = parse_uuid(group_id)
uid = parse_uuid(str(data.get("user_id") or ""))
async with session_scope() as db:
group = await db.get(Group, gid) if gid else None
if group is None:
return not_found()
if uid is None or await db.get(User, uid) is None:
return json_error("that person isn't on this instance", 400)
added = await db.execute(
insert(GroupMember)
.values(id=uuid.uuid4(), group_id=group.id, user_id=uid)
.on_conflict_do_nothing(constraint="uq_group_members_group_user")
)
if added.rowcount:
await joined_group(db, group.id, uid)
await db.commit()
return await _answer(db, group, 201)
@bp.delete("/<group_id>/members/<user_id>")
@require_admin
async def remove_member(group_id: str, user_id: str):
gid = parse_uuid(group_id)
uid = parse_uuid(user_id)
async with session_scope() as db:
group = await db.get(Group, gid) if gid else None
if group is None or uid is None:
return not_found()
membership = await db.scalar(
select(GroupMember).where(GroupMember.group_id == group.id, GroupMember.user_id == uid)
)
if membership is None:
return not_found()
note_ids = await group_notes(db, group.id)
await db.delete(membership)
await db.flush()
await left_group(db, group.id, uid, note_ids)
await db.commit()
return await _answer(db, group)
+36 -2
View File
@@ -16,6 +16,7 @@ from sqlalchemy import select, text
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from .models.group import GroupMember from .models.group import GroupMember
from .models.note import Note
from .models.share import Share from .models.share import Share
from .models.share_revocation import ShareRevocation from .models.share_revocation import ShareRevocation
@@ -31,7 +32,8 @@ async def bump_note(db, note_id: uuid.UUID) -> None:
async def recipients(db, note_id: uuid.UUID) -> set[uuid.UUID]: async def recipients(db, note_id: uuid.UUID) -> set[uuid.UUID]:
"""Everyone the note is shared with, directly or through a group.""" """Everyone the note is shared with, directly or through a group. Never its owner,
who may be in a group it is shared with and holds it as their own regardless."""
direct = await db.scalars( direct = await db.scalars(
select(Share.shared_with_user_id).where( select(Share.shared_with_user_id).where(
Share.resource_type == "note", Share.resource_id == note_id, Share.shared_with_user_id.is_not(None) Share.resource_type == "note", Share.resource_id == note_id, Share.shared_with_user_id.is_not(None)
@@ -42,7 +44,8 @@ async def recipients(db, note_id: uuid.UUID) -> set[uuid.UUID]:
.join(Share, Share.shared_with_group_id == GroupMember.group_id) .join(Share, Share.shared_with_group_id == GroupMember.group_id)
.where(Share.resource_type == "note", Share.resource_id == note_id) .where(Share.resource_type == "note", Share.resource_id == note_id)
) )
return set(direct.all()) | set(grouped.all()) owner = await db.scalar(select(Note.owner_id).where(Note.id == note_id))
return (set(direct.all()) | set(grouped.all())) - {owner}
async def revoke(db, note_id: uuid.UUID, user_ids) -> None: async def revoke(db, note_id: uuid.UUID, user_ids) -> None:
@@ -67,3 +70,34 @@ async def granted(db, note_id: uuid.UUID, user_id: uuid.UUID) -> None:
sa_delete(ShareRevocation).where(ShareRevocation.note_id == note_id, ShareRevocation.user_id == user_id) sa_delete(ShareRevocation).where(ShareRevocation.note_id == note_id, ShareRevocation.user_id == user_id)
) )
await bump_note(db, note_id) await bump_note(db, note_id)
# --- Group membership (#5177) ------------------------------------------------
#
# A note shared with a group reaches whoever is in it NOW, so joining or leaving
# changes what that one person can see without any share changing. Their devices
# hear it the same way as for a direct share: a grant moves the note past their
# cursor, and losing it leaves a revocation.
async def group_notes(db, group_id: uuid.UUID) -> list[uuid.UUID]:
"""The notes shared with this group."""
rows = await db.scalars(
select(Share.resource_id).where(Share.resource_type == "note", Share.shared_with_group_id == group_id)
)
return list(rows.all())
async def joined_group(db, group_id: uuid.UUID, user_id: uuid.UUID) -> None:
"""`user_id` was added to the group: each of its notes reaches their devices."""
for note_id in await group_notes(db, group_id):
await granted(db, note_id, user_id)
async def left_group(db, group_id: uuid.UUID, user_id: uuid.UUID, note_ids: list[uuid.UUID]) -> None:
"""`user_id` is no longer in the group (the membership row is gone and flushed):
each of `note_ids`, the group's notes from before, leaves their devices unless a
direct share or another group still reaches them."""
for note_id in note_ids:
if user_id not in await recipients(db, note_id):
await revoke(db, note_id, [user_id])
+69 -29
View File
@@ -2,7 +2,8 @@
`acl.visible_to_user` has gated every read since M0, but nothing wrote a share. This `acl.visible_to_user` has gated every read since M0, but nothing wrote a share. This
is the writing half: the note's owner lists, grants and revokes access, and anyone is the writing half: the note's owner lists, grants and revokes access, and anyone
signed in can read the member directory the Share dialog picks people from. signed in can read the member directory the Share dialog picks people from. A share
goes to one person or to a group the admin made (#5177).
What a share grants is in `acl` (view, or edit: the body and checklist) and is What a share grants is in `acl` (view, or edit: the body and checklist) and is
enforced by the note routes (`notes.helpers._get_owned` / `_get_editable`). enforced by the note routes (`notes.helpers._get_owned` / `_get_editable`).
@@ -12,13 +13,14 @@ from __future__ import annotations
import uuid import uuid
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from .acl import PERMISSIONS from .acl import PERMISSIONS
from .auth import login_required from .auth import login_required
from .common import iso from .common import iso
from .db import session_scope from .db import session_scope
from .models.group import Group, GroupMember
from .models.share import Share from .models.share import Share
from .models.user import User from .models.user import User
from .notes.helpers import _get_owned from .notes.helpers import _get_owned
@@ -32,32 +34,61 @@ def _member(user: User) -> dict:
return {"id": str(user.id), "display_name": user.display_name, "email": user.email} return {"id": str(user.id), "display_name": user.display_name, "email": user.email}
def _group(group: Group, counts: dict) -> dict:
return {"id": str(group.id), "name": group.name, "member_count": counts.get(group.id, 0)}
async def _member_counts(db, group_ids: list) -> dict:
if not group_ids:
return {}
rows = await db.execute(
select(GroupMember.group_id, func.count())
.where(GroupMember.group_id.in_(group_ids))
.group_by(GroupMember.group_id)
)
return dict(rows.all())
@bp.get("/users/directory") @bp.get("/users/directory")
@login_required @login_required
async def directory(): async def directory():
"""The people on this instance a note can be shared with: everyone but you. """Who on this instance a note can be shared with: everyone but you, and every
group the admin has made (#5177).
Signed-in only. Each entry is a name and an email and nothing more, which is what Signed-in only. Each person is a name and an email and nothing more, which is
choosing someone takes and all a fellow member needs to know.""" what choosing someone takes and all a fellow member needs to know; a group is its
name and how many people are in it."""
async with session_scope() as db: async with session_scope() as db:
users = ( users = (
await db.scalars(select(User).where(User.id != g.user_id).order_by(User.display_name, User.email)) await db.scalars(select(User).where(User.id != g.user_id).order_by(User.display_name, User.email))
).all() ).all()
return jsonify({"members": [_member(u) for u in users]}) groups = (await db.scalars(select(Group).order_by(func.lower(Group.name)))).all()
counts = await _member_counts(db, [gr.id for gr in groups])
return jsonify({"members": [_member(u) for u in users], "groups": [_group(gr, counts) for gr in groups]})
async def _shares_of(db, note_id: uuid.UUID) -> list[dict]: async def _shares_of(db, note_id: uuid.UUID) -> list[dict]:
"""Each share of the note, to a person (`member`) or to a group (`group`); the
other of the two is null."""
rows = ( rows = (
await db.execute( await db.execute(
select(Share, User) select(Share, User, Group)
.join(User, User.id == Share.shared_with_user_id) .outerjoin(User, User.id == Share.shared_with_user_id)
.outerjoin(Group, Group.id == Share.shared_with_group_id)
.where(Share.resource_type == "note", Share.resource_id == note_id) .where(Share.resource_type == "note", Share.resource_id == note_id)
.order_by(Share.created_at) .order_by(Share.created_at)
) )
).all() ).all()
counts = await _member_counts(db, [gr.id for _, _, gr in rows if gr is not None])
return [ return [
{"id": str(share.id), "member": _member(user), "permission": share.permission, "created_at": iso(share.created_at)} {
for share, user in rows "id": str(share.id),
"member": _member(user) if user is not None else None,
"group": _group(group, counts) if group is not None else None,
"permission": share.permission,
"created_at": iso(share.created_at),
}
for share, user, group in rows
] ]
@@ -74,39 +105,48 @@ async def list_shares(note_id: str):
@bp.post("/notes/<note_id>/shares") @bp.post("/notes/<note_id>/shares")
@login_required @login_required
async def share_note(note_id: str): async def share_note(note_id: str):
"""Share the note with one member at `view` or `edit`. Sharing again with the same """Share the note at `view` or `edit` with one member (`user_id`) or with a group
person changes their permission rather than adding a second grant.""" (`group_id`). Sharing again with the same person or group changes the permission
rather than adding a second grant."""
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
permission = data.get("permission") or "view" permission = data.get("permission") or "view"
if permission not in PERMISSIONS: if permission not in PERMISSIONS:
return json_error("permission must be view or edit", 400) return json_error("permission must be view or edit", 400)
target = parse_uuid(str(data.get("user_id") or "")) user_id = parse_uuid(str(data.get("user_id") or ""))
if target is None: group_id = parse_uuid(str(data.get("group_id") or ""))
return json_error("choose someone to share with", 400) if (user_id is None) == (group_id is None):
return json_error("choose someone or a group to share with", 400)
async with session_scope() as db: async with session_scope() as db:
note = await _get_owned(db, note_id) note = await _get_owned(db, note_id)
if note is None: if note is None:
return not_found() return not_found()
if target == g.user_id: if user_id is not None:
return json_error("this note is already yours", 400) if user_id == g.user_id:
if await db.get(User, target) is None: return json_error("this note is already yours", 400)
return json_error("that person isn't on this instance", 400) if await db.get(User, user_id) is None:
return json_error("that person isn't on this instance", 400)
target_column, target = Share.shared_with_user_id, {"shared_with_user_id": user_id}
reached = {user_id}
else:
if await db.get(Group, group_id) is None:
return json_error("that group isn't on this instance", 400)
target_column, target = Share.shared_with_group_id, {"shared_with_group_id": group_id}
reached = set(
(await db.scalars(select(GroupMember.user_id).where(GroupMember.group_id == group_id))).all()
)
await db.execute( await db.execute(
insert(Share) insert(Share)
.values( .values(id=uuid.uuid4(), resource_type="note", resource_id=note.id, permission=permission, **target)
id=uuid.uuid4(),
resource_type="note",
resource_id=note.id,
shared_with_user_id=target,
permission=permission,
)
.on_conflict_do_update( .on_conflict_do_update(
index_elements=[Share.resource_type, Share.resource_id, Share.shared_with_user_id], index_elements=[Share.resource_type, Share.resource_id, target_column],
index_where=Share.shared_with_user_id.is_not(None), index_where=target_column.is_not(None),
set_={"permission": permission}, set_={"permission": permission},
) )
) )
await granted(db, note.id, target) for uid in reached - {g.user_id}:
await granted(db, note.id, uid)
# The owner's devices learn the note is shared, even with an empty group.
await bump_note(db, note.id)
await db.commit() await db.commit()
return jsonify({"shares": await _shares_of(db, note.id)}), 201 return jsonify({"shares": await _shares_of(db, note.id)}), 201
+81 -1
View File
@@ -51,7 +51,7 @@ pytestmark = pytest.mark.integration
# Every table the tests touch, child-first so FKs never block the truncate. # Every table the tests touch, child-first so FKs never block the truncate.
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later. # RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, note_user_state, invites, password_resets, users" _TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, share_revocations, note_user_state, group_members, groups, invites, password_resets, users"
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -1511,6 +1511,86 @@ async def test_deleting_a_shared_note_reaches_the_recipients_devices(app_client,
assert _feed_note(gone, nid) is None assert _feed_note(gone, nid) is None
# --- Groups (#5177) ------------------------------------------------------------
async def _group(app_client, name: str, *member_ids: str) -> str:
made = await app_client.post("/api/groups", json={"name": name})
assert made.status_code == 201, await made.get_data(as_text=True)
gid = (await made.get_json())["id"]
for uid in member_ids:
added = await app_client.post(f"/api/groups/{gid}/members", json={"user_id": uid})
assert added.status_code == 201, await added.get_data(as_text=True)
return gid
async def test_only_an_admin_manages_groups_and_everyone_can_share_with_one(app_client, db):
recipient, _, people = await _three_people(app_client)
assert (await recipient.get("/api/groups")).status_code == 403
assert (await recipient.post("/api/groups", json={"name": "Mine"})).status_code == 403
gid = await _group(app_client, "Family", people["recipient"])
assert (await app_client.post("/api/groups", json={"name": "family"})).status_code == 409
assert (await app_client.post("/api/groups", json={"name": " "})).status_code == 400
listed = (await (await app_client.get("/api/groups")).get_json())["groups"]
assert [(gr["name"], [m["email"] for m in gr["members"]]) for gr in listed] == [
("Family", ["recipient@example.test"])
]
renamed = await app_client.patch(f"/api/groups/{gid}", json={"name": "Household"})
assert (await renamed.get_json())["name"] == "Household"
# Anyone signed in sees each group to share with, and how many are in it.
directory = await (await recipient.get("/api/users/directory")).get_json()
assert directory["groups"] == [{"id": gid, "name": "Household", "member_count": 1}]
async def test_a_note_shared_with_a_group_follows_who_is_in_it(app_client, db):
recipient, stranger, people = await _three_people(app_client)
gid = await _group(app_client, "Family", people["recipient"])
nid = await _owners_note(app_client)
start = (await _feed(stranger))["cursor"]
shared = await app_client.post(f"/api/notes/{nid}/shares", json={"group_id": gid, "permission": "edit"})
assert shared.status_code == 201, await shared.get_data(as_text=True)
[entry] = (await shared.get_json())["shares"]
assert (entry["member"], entry["group"]["name"], entry["group"]["member_count"]) == (None, "Family", 1)
assert (await (await recipient.get(f"/api/notes/{nid}")).get_json())["permission"] == "edit"
assert (await stranger.get(f"/api/notes/{nid}")).status_code == 404
both = await app_client.post(f"/api/notes/{nid}/shares", json={"group_id": gid, "user_id": people["stranger"]})
assert both.status_code == 400
# Joining reaches the new member's devices; leaving takes it back.
await app_client.post(f"/api/groups/{gid}/members", json={"user_id": people["stranger"]})
joined = await _feed(stranger, start)
assert _feed_note(joined, nid)["permission"] == "edit"
left = await app_client.delete(f"/api/groups/{gid}/members/{people['stranger']}")
assert left.status_code == 200
gone = await _feed(stranger, joined["cursor"])
assert gone["revoked"] == [nid]
assert (await stranger.get(f"/api/notes/{nid}")).status_code == 404
# Someone also shared with directly keeps the note when they leave the group.
await _share(app_client, nid, people["recipient"], "view")
before = (await _feed(recipient))["cursor"]
await app_client.delete(f"/api/groups/{gid}/members/{people['recipient']}")
assert (await _feed(recipient, before))["revoked"] == []
assert (await (await recipient.get(f"/api/notes/{nid}")).get_json())["permission"] == "view"
async def test_deleting_a_group_ends_every_share_made_to_it(app_client, db):
recipient, _, people = await _three_people(app_client)
gid = await _group(app_client, "Family", people["recipient"])
nid = await _owners_note(app_client)
await app_client.post(f"/api/notes/{nid}/shares", json={"group_id": gid})
seen = await _feed(recipient)
assert _feed_note(seen, nid) is not None
assert (await app_client.delete(f"/api/groups/{gid}")).status_code == 200
assert (await _feed(recipient, seen["cursor"]))["revoked"] == [nid]
assert (await recipient.get(f"/api/notes/{nid}")).status_code == 404
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["shared"] is False
# --- Password reset by email (#5266) ------------------------------------------ # --- Password reset by email (#5266) ------------------------------------------
# #
# The SMTP hand-off is replaced by a list; everything up to it is real. # The SMTP hand-off is replaced by a list; everything up to it is real.