Files
xarmian c6d19837c8 feat: collection & item grants, guest access, share dialog (PLAN-407 Phase 3) (#87)
* feat: collection and item grants tables + permission resolution

Add grant tables, CRUD operations, and permission resolution for
guest access and member overrides (TASK-417).

Data model:
- collection_grants table (id, collection_id, workspace_id, user_id,
  permission, granted_by) with CASCADE on collection/user delete
- item_grants table (same structure, references items)
- Indexes for user/collection/item lookups

Store methods:
- Create/Get/List/Delete for both collection and item grants
- ListUserGrants: all grants for a user across a workspace
- RevokeAllUserGrants: bulk delete for member removal
- ResolveUserPermission: full 5-step resolution per DOC-406
  (owner → item grant → collection grant → membership → deny)

API endpoints:
- GET/POST/DELETE /collections/{coll}/grants — collection grant CRUD
- GET/POST/DELETE /items/{slug}/grants — item grant CRUD
- GET /users/{userID}/grants — all grants for a user in workspace

All grant endpoints are owner-only for creation/deletion.

* feat: grant revocation + member removal with grant choice

Update member removal to support D4: owner chooses whether to revoke
all grants when removing a member (TASK-489).

- DELETE /members/{userID}?revoke_grants=true → remove membership AND
  all collection/item grants (full removal)
- DELETE /members/{userID} (or revoke_grants=false) → remove membership
  but keep grants (user becomes a guest with existing access)
- Audit log records whether grants were revoked
- CASCADE DELETE on collection/item deletion already handles cleanup
  (via ON DELETE CASCADE in the grants migration)

* feat: share dialog UI for items and collections + grant types

Add a share dialog component for managing grants on items and
collections, plus TypeScript types and API client methods (TASK-419).

Frontend:
- ShareDialog.svelte: reusable modal for listing/creating/revoking
  grants, with email input, permission select, and revoke buttons
- Item detail page: "Share" button in meta-actions (owner-only)
- Collection page: "Share" button in header actions (owner-only)

TypeScript:
- CollectionGrant and ItemGrant types added
- API client: grants.listCollectionGrants, createCollectionGrant,
  deleteCollectionGrant, listItemGrants, createItemGrant,
  deleteItemGrant, listUserGrants

Guest home screen (TASK-418) deferred — requires layout-level guest
detection which will be implemented when guest routing is built.

* feat: guest access — grants-based workspace access for non-members

Allow authenticated users with grants (but no workspace membership)
to access workspaces as guests (TASK-418).

Backend:
- UserHasGrantsInWorkspace: checks if user has any collection/item
  grants in a workspace
- GuestVisibleCollectionIDs: returns collections visible to a guest
  via collection grants + collections containing granted items
- RequireWorkspaceAccess: after member-nil check, falls through to
  grant check; sets role to "guest" if grants exist
- VisibleCollectionIDs: non-members now checked for guest grants
  instead of returning empty
- GetUserWorkspaces: includes guest workspaces (is_guest flag)
- GetWorkspacesBySlugForUser: JOINs on grants tables so workspaces
  resolve for guests
- roleLevel: "guest" = 0 (below viewer, blocks role-gated actions)

Frontend:
- Workspace.is_guest field in TypeScript type
- Sidebar: hides Dashboard, Roles, Activity, Settings, and "New
  collection" button for guests; shows "Shared with you" header

* feat: wiki-link rendering with locked icon for hidden items

Update wiki-link rendering to show a 🔒 locked icon when the linked
item is in a collection the user can't see (TASK-420).

- renderMarkdown accepts optional visibleCollectionSlugs parameter
- Items in hidden collections render as "🔒 Title" with tooltip
- Unresolved links still render as broken (no change)
- Username param added to renderMarkdown for correct URL construction
- TimelineCommentCard and CommentThread accept username prop

* fix: harden grant security — 9 findings from Codex review

- Item grants no longer leak collection-wide read access; guests with
  item-level grants see only their granted items, not the full collection
  (GuestVisibleResources two-level filter + ItemIDs in ListItems SQL).
- Edit grants are now enforced: mutating handlers (create/update/delete
  items, comments, reactions, links, versions) resolve grant-based
  permissions for guests via requireEditPermission + ResolveUserPermission.
- Grant list endpoints restricted to owners (collection/item grants) or
  owner-or-self (user grants) to prevent metadata/email enumeration.
- Guests blocked from listing workspace members; invitation details
  restricted to owners only.
- Grant deletion scoped to workspace_id to prevent cross-workspace
  deletion by guessing grant IDs.
- Member removal now revokes grants by default (opt-out with
  ?revoke_grants=false) and propagates revocation errors instead of
  silently discarding them.
- Guest workspace listing properly propagates DB errors instead of
  swallowing them.
- PostgreSQL subquery alias added to UserHasGrantsInWorkspace to fix
  silent guest-access failures on Postgres deployments.

* fix: harden item-level grant isolation — 7 findings from Codex re-review

- /changes endpoint now filters by item-level grants so guests with one
  item grant no longer receive updates for every item in that collection.
- Search results filtered by item-level grants (new ItemIDs field in
  SearchParams) so guests can't discover other items via search.
- Relationship/summary endpoints (item links, children, progress,
  activity, dashboard) all apply item-level visibility checks via
  isItemVisibleToGuest(), preventing metadata leakage through related
  item titles, statuses, and counts.
- Grants now work as member overrides: a viewer with an edit grant can
  edit the granted item (requireEditPermission falls back to
  ResolveUserPermission for members below editor role).
- handleMoveItem now requires edit permission on the target collection,
  not just visibility, preventing guests from moving items into
  view-only collections.
- Member removal + grant revocation is now atomic via
  RemoveWorkspaceMemberAndRevokeGrants() which wraps both operations
  in a single database transaction.
- Guest-access DB errors in middleware now return 500 with slog.Error
  instead of being silently collapsed into a 403 forbidden response.

* fix: close remaining grant isolation gaps — 10 findings from Codex round 3

- Workspace token endpoints (create/list/delete) now require owner role,
  preventing guests from enumerating or revoking API tokens.
- Legacy document endpoints (list, get, context, bulk-read, backlinks,
  links) now require at least viewer role, blocking guests entirely
  since documents are outside the grants model.
- Global search no longer relies on workspaceRole() (which is unset
  outside RequireWorkspaceAccess); detects guests via IsWorkspaceMember
  and applies item-level filtering. Multi-workspace search now uses
  GuestVisibleResources for guest workspaces.
- SSE event filtering now checks item IDs for guests with item-level
  grants, not just collection slugs, preventing live event leaks.
- Role board passes ItemIDs through RoleBoardParams so guests only
  see items they have grants on, not the entire collection.
- VisibleCollectionIDs for members with "specific" collection access
  now merges direct grants (collection + item grants), so grant
  overrides work for restricted members.
- Plans-progress endpoint filters plan items and children by item-level
  grants for guests, preventing one plan grant from exposing all plans.
- Webhook listing now requires owner role since URLs may contain secrets.
- Agent role item counts use item-level filtering for guests.
- Link deletion checks item-level visibility on both endpoints, not
  just collection-level.

* fix: close member grant escalation and remaining edge cases — round 4

- Item grants for restricted members no longer escalate to collection-
  wide visibility. VisibleCollectionIDs now merges only direct collection
  grants (not item-derived collections) into member access. Item-level
  filtering (guestResourceFilter, isItemVisibleToGuest, requireItemVisible)
  now applies to both guests AND restricted members with item grants,
  closing the gap where a member with specific collection access plus
  one item grant could see/edit all items in that collection.
- Guests blocked from workspace-level activity feed (/activity) which
  exposed audit events (member invites, role changes) with operational
  metadata. Requires at least viewer role.
- Global search no longer returns zero results for item-only guests.
  Store.Search early-return now checks both CollectionIDs and ItemIDs
  are empty before short-circuiting, so item-level grants work in
  global (multi-workspace) search.
- UserHasGrantsInWorkspace now excludes item grants on soft-deleted
  items, preventing phantom guest access to a workspace shell with
  no visible content when the only granted item is archived.

* fix: prevent grant filter from overriding member access, close SSE/dashboard/collection leaks — round 5

- guestResourceFilter now returns nil/nil for members with "all"
  collection access, preventing item grants from accidentally replacing
  their full visibility. Only guests and members with "specific"
  collection access get item-level filtering applied. This fixes a
  regression where a normal member receiving one item grant would lose
  access to all other items.
- requireItemVisible uses guestResourceFilter (with the same scoping)
  instead of raw GuestVisibleResources, so the member-access check is
  consistent throughout all code paths.
- SSE event filtering now denies collection-less events (workspace
  updates, legacy document events) for guests, preventing metadata
  leakage through realtime event payloads.
- Dashboard recent activity filters out workspace-level entries (no
  DocumentID) for guests, preventing audit metadata leakage.
- All grant visibility queries (UserHasGrantsInWorkspace,
  GuestVisibleCollectionIDs, GuestVisibleResources) now join the
  collections table and require deleted_at IS NULL, so grants on
  soft-deleted collections no longer provide phantom access.

* fix: make item grants additive for restricted members, close write/search/SSE gaps — round 6

- guestResourceFilter now merges member_collection_access + system
  collections + collection grants into fullCollIDs for restricted members,
  making item grants additive to existing access. Previously, item grants
  replaced the member's normal collections, causing members with one item
  grant to lose all their other collection visibility.
- Added ListSystemCollectionIDs store method for system collection lookup.
- Search (both global and workspace-scoped) now applies item-level
  filtering for restricted members with item grants, not just guests.
  Previously VisibleCollectionIDs included item-granted collections as
  full-access, leaking all items in those collections via search.
- SSE event filtering now builds item-level filters for restricted
  members with item grants (previously only for non-members/guests),
  and merges member collections into the full-access set.
- Role board reorder now uses requireItemVisible + requireEditPermission
  per item instead of collection-only visibility check, preventing
  restricted editors from reordering items in item-granted collections.
- View create/update/delete now check requireEditPermission on the
  collection (via requireViewEditable), not just collection visibility.
- GetUserWorkspaces guest query now joins collections/items tables to
  exclude grants on soft-deleted resources, matching the behavior of
  UserHasGrantsInWorkspace.

* fix: block guests from legacy doc versions/activity, fix ListItems early return, SSE fail-closed — round 7

- Legacy document version handlers (handleListVersions, handleGetVersion)
  and document activity handler (handleListDocumentActivity) now require
  at least viewer role, blocking guests from reading version history and
  activity for unrelated legacy documents.
- ListItems early return now checks both CollectionIDs and ItemIDs are
  empty before short-circuiting, matching the fix already applied to
  Search. This fixes item-only guests seeing zero results from /items,
  dashboard, role board, and agent-role counts.
- SSE item-grant filtering now fails closed on GuestVisibleResources
  errors: installs empty item/collection filter sets instead of falling
  through with nil (which would pass all events through).
- Role board reorder removed top-level requireMinRole("editor") so the
  per-item grant-aware requireEditPermission checks can run for guests
  and viewers with edit grants, consistent with other mutating handlers.
2026-04-11 14:46:24 -04:00

38 lines
1.3 KiB
Go

package models
import "time"
// CollectionGrant represents a user's direct access to a collection
// (for guests or member overrides).
type CollectionGrant struct {
ID string `json:"id"`
CollectionID string `json:"collection_id"`
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
Permission string `json:"permission"` // "view" or "edit"
GrantedBy string `json:"granted_by"`
CreatedAt time.Time `json:"created_at"`
// Populated by JOINs (not stored)
UserName string `json:"user_name,omitempty"`
UserEmail string `json:"user_email,omitempty"`
UserUsername string `json:"user_username,omitempty"`
}
// ItemGrant represents a user's direct access to a specific item
// (for guests or member overrides).
type ItemGrant struct {
ID string `json:"id"`
ItemID string `json:"item_id"`
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
Permission string `json:"permission"` // "view" or "edit"
GrantedBy string `json:"granted_by"`
CreatedAt time.Time `json:"created_at"`
// Populated by JOINs (not stored)
UserName string `json:"user_name,omitempty"`
UserEmail string `json:"user_email,omitempty"`
UserUsername string `json:"user_username,omitempty"`
}