mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
9ad718178d
ResolveUserPermission matched item grants on item_id alone, so a grant on an item in workspace B resolved for a request scoped to workspace A. This is the underlying lookup behind the delete escalation PLAN-2382 fixed at the handler; closing it here means the next caller does not have to remember the workspace-identity guard. The adjacent collection-grant lookup had the identical defect and the identical safety argument, so it is scoped in the same commit rather than leaving a second unscoped lookup three lines below the one DR-5 names. Safe for every caller: all three (requireEditPermission, the collab access check, crossWorkspaceEditAllowed) already pass the workspace the item/collection was resolved in, and grant rows carry the workspace they were minted in — the same scoping listUserItemGrants already uses. Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
555 lines
18 KiB
Go
555 lines
18 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// --- Collection Grants ---
|
|
|
|
// CreateCollectionGrant creates a direct grant on a collection for a user.
|
|
func (s *Store) CreateCollectionGrant(workspaceID, collectionID, userID, permission, grantedBy string) (*models.CollectionGrant, error) {
|
|
id := newID()
|
|
ts := now()
|
|
|
|
_, err := s.db.Exec(s.q(`
|
|
INSERT INTO collection_grants (id, collection_id, workspace_id, user_id, permission, granted_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`), id, collectionID, workspaceID, userID, permission, grantedBy, ts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create collection grant: %w", err)
|
|
}
|
|
|
|
return s.GetCollectionGrant(id)
|
|
}
|
|
|
|
// GetCollectionGrant retrieves a collection grant by ID.
|
|
func (s *Store) GetCollectionGrant(id string) (*models.CollectionGrant, error) {
|
|
var g models.CollectionGrant
|
|
var createdAt string
|
|
|
|
err := s.db.QueryRow(s.q(`
|
|
SELECT cg.id, cg.collection_id, cg.workspace_id, cg.user_id, cg.permission, cg.granted_by, cg.created_at,
|
|
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
|
|
FROM collection_grants cg
|
|
LEFT JOIN users u ON u.id = cg.user_id
|
|
WHERE cg.id = ?
|
|
`), id).Scan(
|
|
&g.ID, &g.CollectionID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
|
|
&g.UserName, &g.UserEmail, &g.UserUsername,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get collection grant: %w", err)
|
|
}
|
|
|
|
g.CreatedAt = parseTime(createdAt)
|
|
return &g, nil
|
|
}
|
|
|
|
// ListCollectionGrants returns all grants on a collection.
|
|
func (s *Store) ListCollectionGrants(collectionID string) ([]models.CollectionGrant, error) {
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT cg.id, cg.collection_id, cg.workspace_id, cg.user_id, cg.permission, cg.granted_by, cg.created_at,
|
|
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
|
|
FROM collection_grants cg
|
|
LEFT JOIN users u ON u.id = cg.user_id
|
|
WHERE cg.collection_id = ?
|
|
ORDER BY cg.created_at ASC
|
|
`), collectionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list collection grants: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.CollectionGrant
|
|
for rows.Next() {
|
|
var g models.CollectionGrant
|
|
var createdAt string
|
|
if err := rows.Scan(
|
|
&g.ID, &g.CollectionID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
|
|
&g.UserName, &g.UserEmail, &g.UserUsername,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
g.CreatedAt = parseTime(createdAt)
|
|
result = append(result, g)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// DeleteCollectionGrant revokes a collection grant by ID, scoped to a workspace.
|
|
func (s *Store) DeleteCollectionGrant(id, workspaceID string) error {
|
|
result, err := s.db.Exec(s.q("DELETE FROM collection_grants WHERE id = ? AND workspace_id = ?"), id, workspaceID)
|
|
if err != nil {
|
|
return fmt.Errorf("delete collection grant: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Item Grants ---
|
|
|
|
// CreateItemGrant creates a direct grant on an item for a user.
|
|
func (s *Store) CreateItemGrant(workspaceID, itemID, userID, permission, grantedBy string) (*models.ItemGrant, error) {
|
|
id := newID()
|
|
ts := now()
|
|
|
|
_, err := s.db.Exec(s.q(`
|
|
INSERT INTO item_grants (id, item_id, workspace_id, user_id, permission, granted_by, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`), id, itemID, workspaceID, userID, permission, grantedBy, ts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create item grant: %w", err)
|
|
}
|
|
|
|
return s.GetItemGrant(id)
|
|
}
|
|
|
|
// GetItemGrant retrieves an item grant by ID.
|
|
func (s *Store) GetItemGrant(id string) (*models.ItemGrant, error) {
|
|
var g models.ItemGrant
|
|
var createdAt string
|
|
|
|
err := s.db.QueryRow(s.q(`
|
|
SELECT ig.id, ig.item_id, ig.workspace_id, ig.user_id, ig.permission, ig.granted_by, ig.created_at,
|
|
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
|
|
FROM item_grants ig
|
|
LEFT JOIN users u ON u.id = ig.user_id
|
|
WHERE ig.id = ?
|
|
`), id).Scan(
|
|
&g.ID, &g.ItemID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
|
|
&g.UserName, &g.UserEmail, &g.UserUsername,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get item grant: %w", err)
|
|
}
|
|
|
|
g.CreatedAt = parseTime(createdAt)
|
|
return &g, nil
|
|
}
|
|
|
|
// ListItemGrants returns all grants on an item.
|
|
func (s *Store) ListItemGrants(itemID string) ([]models.ItemGrant, error) {
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT ig.id, ig.item_id, ig.workspace_id, ig.user_id, ig.permission, ig.granted_by, ig.created_at,
|
|
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
|
|
FROM item_grants ig
|
|
LEFT JOIN users u ON u.id = ig.user_id
|
|
WHERE ig.item_id = ?
|
|
ORDER BY ig.created_at ASC
|
|
`), itemID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list item grants: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.ItemGrant
|
|
for rows.Next() {
|
|
var g models.ItemGrant
|
|
var createdAt string
|
|
if err := rows.Scan(
|
|
&g.ID, &g.ItemID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
|
|
&g.UserName, &g.UserEmail, &g.UserUsername,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
g.CreatedAt = parseTime(createdAt)
|
|
result = append(result, g)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// DeleteItemGrant revokes an item grant by ID, scoped to a workspace.
|
|
func (s *Store) DeleteItemGrant(id, workspaceID string) error {
|
|
result, err := s.db.Exec(s.q("DELETE FROM item_grants WHERE id = ? AND workspace_id = ?"), id, workspaceID)
|
|
if err != nil {
|
|
return fmt.Errorf("delete item grant: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return sql.ErrNoRows
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Cross-cutting queries ---
|
|
|
|
// ListUserGrants returns all collection and item grants for a user in a workspace.
|
|
func (s *Store) ListUserGrants(workspaceID, userID string) ([]models.CollectionGrant, []models.ItemGrant, error) {
|
|
collGrants, err := s.listUserCollectionGrants(workspaceID, userID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
itemGrants, err := s.listUserItemGrants(workspaceID, userID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return collGrants, itemGrants, nil
|
|
}
|
|
|
|
func (s *Store) listUserCollectionGrants(workspaceID, userID string) ([]models.CollectionGrant, error) {
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT cg.id, cg.collection_id, cg.workspace_id, cg.user_id, cg.permission, cg.granted_by, cg.created_at,
|
|
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
|
|
FROM collection_grants cg
|
|
LEFT JOIN users u ON u.id = cg.user_id
|
|
WHERE cg.workspace_id = ? AND cg.user_id = ?
|
|
ORDER BY cg.created_at ASC
|
|
`), workspaceID, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list user collection grants: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.CollectionGrant
|
|
for rows.Next() {
|
|
var g models.CollectionGrant
|
|
var createdAt string
|
|
if err := rows.Scan(
|
|
&g.ID, &g.CollectionID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
|
|
&g.UserName, &g.UserEmail, &g.UserUsername,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
g.CreatedAt = parseTime(createdAt)
|
|
result = append(result, g)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (s *Store) listUserItemGrants(workspaceID, userID string) ([]models.ItemGrant, error) {
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT ig.id, ig.item_id, ig.workspace_id, ig.user_id, ig.permission, ig.granted_by, ig.created_at,
|
|
COALESCE(u.name, ''), COALESCE(u.email, ''), COALESCE(u.username, '')
|
|
FROM item_grants ig
|
|
LEFT JOIN users u ON u.id = ig.user_id
|
|
WHERE ig.workspace_id = ? AND ig.user_id = ?
|
|
ORDER BY ig.created_at ASC
|
|
`), workspaceID, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list user item grants: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []models.ItemGrant
|
|
for rows.Next() {
|
|
var g models.ItemGrant
|
|
var createdAt string
|
|
if err := rows.Scan(
|
|
&g.ID, &g.ItemID, &g.WorkspaceID, &g.UserID, &g.Permission, &g.GrantedBy, &createdAt,
|
|
&g.UserName, &g.UserEmail, &g.UserUsername,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
g.CreatedAt = parseTime(createdAt)
|
|
result = append(result, g)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// RevokeAllUserGrants deletes all collection and item grants for a user in a workspace.
|
|
// Used when removing a member with "revoke all access" option.
|
|
func (s *Store) RevokeAllUserGrants(workspaceID, userID string) error {
|
|
_, err := s.db.Exec(s.q("DELETE FROM collection_grants WHERE workspace_id = ? AND user_id = ?"), workspaceID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke collection grants: %w", err)
|
|
}
|
|
_, err = s.db.Exec(s.q("DELETE FROM item_grants WHERE workspace_id = ? AND user_id = ?"), workspaceID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke item grants: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResolveUserPermission resolves the effective permission for a user on a specific
|
|
// item, following the permission resolution order from DOC-406:
|
|
// 1. Owner bypass → 2. Item grant → 3. Collection grant → 4. Membership → 5. Deny
|
|
// Returns the permission string ("view", "edit", "admin", "owner") or "" if denied.
|
|
func (s *Store) ResolveUserPermission(workspaceID, userID, itemID, collectionID string) (string, error) {
|
|
// 1. Is user the workspace owner?
|
|
var ownerID string
|
|
err := s.db.QueryRow(s.q("SELECT owner_id FROM workspaces WHERE id = ?"), workspaceID).Scan(&ownerID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("check workspace owner: %w", err)
|
|
}
|
|
if ownerID == userID {
|
|
return "owner", nil
|
|
}
|
|
|
|
// 2. Item-level grant?
|
|
//
|
|
// Scoped by workspace_id (PLAN-2391 DR-5). The grant rows already carry
|
|
// the workspace they were minted in (CreateItemGrant, and the same
|
|
// scoping listUserItemGrants uses), and every caller passes the
|
|
// workspace the item was resolved in — so this narrows nothing
|
|
// legitimate. What it closes is a caller handing us an itemID from a
|
|
// FOREIGN workspace alongside the request's own workspaceID: without
|
|
// the predicate, that foreign item's grant resolved and answered for
|
|
// the wrong workspace. That is the shape of the delete escalation
|
|
// PLAN-2382 fixed at the handler (internal/server/handlers_storage.go);
|
|
// this closes it at the lookup so the next caller need not remember.
|
|
if itemID != "" {
|
|
var perm string
|
|
err := s.db.QueryRow(s.q(
|
|
"SELECT permission FROM item_grants WHERE workspace_id = ? AND item_id = ? AND user_id = ?"),
|
|
workspaceID, itemID, userID).Scan(&perm)
|
|
if err == nil {
|
|
return perm, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return "", fmt.Errorf("check item grant: %w", err)
|
|
}
|
|
}
|
|
|
|
// 3. Collection-level grant?
|
|
//
|
|
// Workspace-scoped for the same reason as the item grant above — the
|
|
// defect and its safety argument are identical, so the two are fixed
|
|
// together rather than leaving a second unscoped lookup three lines
|
|
// below the one DR-5 names.
|
|
if collectionID != "" {
|
|
var perm string
|
|
err := s.db.QueryRow(s.q(
|
|
"SELECT permission FROM collection_grants WHERE workspace_id = ? AND collection_id = ? AND user_id = ?"),
|
|
workspaceID, collectionID, userID).Scan(&perm)
|
|
if err == nil {
|
|
return perm, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return "", fmt.Errorf("check collection grant: %w", err)
|
|
}
|
|
}
|
|
|
|
// 4. Workspace membership?
|
|
member, err := s.GetWorkspaceMember(workspaceID, userID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("check membership: %w", err)
|
|
}
|
|
if member != nil {
|
|
// Check collection visibility for members with "specific" access
|
|
if collectionID != "" && member.CollectionAccess == "specific" {
|
|
visIDs, err := s.VisibleCollectionIDs(workspaceID, userID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
visible := false
|
|
for _, id := range visIDs {
|
|
if id == collectionID {
|
|
visible = true
|
|
break
|
|
}
|
|
}
|
|
if !visible {
|
|
return "", nil // Collection not visible → deny
|
|
}
|
|
}
|
|
return member.Role, nil // "owner", "editor", or "viewer"
|
|
}
|
|
|
|
// 5. Deny
|
|
return "", nil
|
|
}
|
|
|
|
// UserHasGrantsInWorkspace checks if a user has any active collection or item
|
|
// grants in a workspace (even though they are not a member). Used to detect guests.
|
|
// Grants on soft-deleted items or soft-deleted collections are excluded so
|
|
// archived resources don't create phantom guest access.
|
|
func (s *Store) UserHasGrantsInWorkspace(workspaceID, userID string) (bool, error) {
|
|
var count int
|
|
err := s.db.QueryRow(s.q(`
|
|
SELECT COUNT(*) FROM (
|
|
SELECT 1 FROM collection_grants cg
|
|
JOIN collections c ON c.id = cg.collection_id
|
|
WHERE cg.workspace_id = ? AND cg.user_id = ? AND c.deleted_at IS NULL
|
|
UNION ALL
|
|
SELECT 1 FROM item_grants ig
|
|
JOIN items i ON i.id = ig.item_id
|
|
JOIN collections c ON c.id = i.collection_id
|
|
WHERE ig.workspace_id = ? AND ig.user_id = ? AND i.deleted_at IS NULL AND c.deleted_at IS NULL
|
|
LIMIT 1
|
|
) AS grant_check
|
|
`), workspaceID, userID, workspaceID, userID).Scan(&count)
|
|
if err != nil {
|
|
return false, fmt.Errorf("check user grants: %w", err)
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// GuestVisibleCollectionIDs returns the collection IDs a guest (non-member with
|
|
// grants) can see. Includes collections with direct collection_grants and
|
|
// collections that contain items the user has item_grants on.
|
|
func (s *Store) GuestVisibleCollectionIDs(workspaceID, userID string) ([]string, error) {
|
|
ids := make(map[string]bool)
|
|
|
|
// Collections with direct grants (excluding soft-deleted collections)
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT DISTINCT cg.collection_id FROM collection_grants cg
|
|
JOIN collections c ON c.id = cg.collection_id
|
|
WHERE cg.workspace_id = ? AND cg.user_id = ? AND c.deleted_at IS NULL
|
|
`), workspaceID, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("guest collection grants: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
ids[id] = true
|
|
}
|
|
|
|
// Collections containing items with direct grants (excluding soft-deleted)
|
|
itemRows, err := s.db.Query(s.q(`
|
|
SELECT DISTINCT i.collection_id
|
|
FROM item_grants ig
|
|
JOIN items i ON i.id = ig.item_id
|
|
JOIN collections c ON c.id = i.collection_id
|
|
WHERE ig.workspace_id = ? AND ig.user_id = ? AND i.deleted_at IS NULL AND c.deleted_at IS NULL
|
|
`), workspaceID, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("guest item grant collections: %w", err)
|
|
}
|
|
defer itemRows.Close()
|
|
for itemRows.Next() {
|
|
var id string
|
|
if err := itemRows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
ids[id] = true
|
|
}
|
|
|
|
result := make([]string, 0, len(ids))
|
|
for id := range ids {
|
|
result = append(result, id)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GuestVisibleResourcesIncludeDeleted is the delta-sync variant of
|
|
// GuestVisibleResources: it does NOT filter out soft-deleted items
|
|
// or collections. /items-changes (TASK-1354) must surface a
|
|
// tombstone for any item the client previously saw, including items
|
|
// the user had an item-level grant on. Without this variant, a
|
|
// grant-only user whose granted item gets soft-deleted would see
|
|
// the row vanish from /items-changes with no `deleted:true` signal,
|
|
// and the client would keep the stale entry in its local index
|
|
// forever (Codex review of TASK-1354 round 1 [P1]).
|
|
//
|
|
// Same shape as GuestVisibleResources so the caller can swap them
|
|
// in/out depending on whether the endpoint needs live-state or
|
|
// tombstone-bearing visibility.
|
|
func (s *Store) GuestVisibleResourcesIncludeDeleted(workspaceID, userID string) (fullCollectionIDs []string, grantedItemIDs []string, err error) {
|
|
// Collections with direct grants — include soft-deleted
|
|
// collections so the client can flush their items from its
|
|
// local index via the items query below.
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT DISTINCT cg.collection_id FROM collection_grants cg
|
|
WHERE cg.workspace_id = ? AND cg.user_id = ?
|
|
`), workspaceID, userID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("guest collection grants (include deleted): %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
fullCollectionIDs = append(fullCollectionIDs, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Item-level grants — INCLUDE soft-deleted items so their
|
|
// tombstones flow through /items-changes. The grant row itself
|
|
// is the source of truth for visibility; the item's
|
|
// deleted_at is what the delta endpoint USES to mark
|
|
// `deleted:true` on the wire.
|
|
itemRows, err := s.db.Query(s.q(`
|
|
SELECT DISTINCT ig.item_id
|
|
FROM item_grants ig
|
|
WHERE ig.workspace_id = ? AND ig.user_id = ?
|
|
`), workspaceID, userID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("guest item grants (include deleted): %w", err)
|
|
}
|
|
defer itemRows.Close()
|
|
for itemRows.Next() {
|
|
var id string
|
|
if err := itemRows.Scan(&id); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
grantedItemIDs = append(grantedItemIDs, id)
|
|
}
|
|
if err := itemRows.Err(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return fullCollectionIDs, grantedItemIDs, nil
|
|
}
|
|
|
|
// GuestVisibleResources returns the two-level visibility for a guest:
|
|
// - fullCollectionIDs: collections where the user has a direct collection grant (full access)
|
|
// - grantedItemIDs: specific item IDs the user has item-level grants on
|
|
// This allows callers to distinguish between full-collection access and item-only access.
|
|
func (s *Store) GuestVisibleResources(workspaceID, userID string) (fullCollectionIDs []string, grantedItemIDs []string, err error) {
|
|
// Collections with direct grants (full collection access, excluding soft-deleted)
|
|
rows, err := s.db.Query(s.q(`
|
|
SELECT DISTINCT cg.collection_id FROM collection_grants cg
|
|
JOIN collections c ON c.id = cg.collection_id
|
|
WHERE cg.workspace_id = ? AND cg.user_id = ? AND c.deleted_at IS NULL
|
|
`), workspaceID, userID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("guest collection grants: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
fullCollectionIDs = append(fullCollectionIDs, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Individual item IDs with direct grants (excluding soft-deleted items/collections)
|
|
itemRows, err := s.db.Query(s.q(`
|
|
SELECT DISTINCT ig.item_id
|
|
FROM item_grants ig
|
|
JOIN items i ON i.id = ig.item_id
|
|
JOIN collections c ON c.id = i.collection_id
|
|
WHERE ig.workspace_id = ? AND ig.user_id = ? AND i.deleted_at IS NULL AND c.deleted_at IS NULL
|
|
`), workspaceID, userID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("guest item grants: %w", err)
|
|
}
|
|
defer itemRows.Close()
|
|
for itemRows.Next() {
|
|
var id string
|
|
if err := itemRows.Scan(&id); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
grantedItemIDs = append(grantedItemIDs, id)
|
|
}
|
|
if err := itemRows.Err(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return fullCollectionIDs, grantedItemIDs, nil
|
|
}
|